@dirwin517/bot-farm 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.idea/bot-farm.iml +9 -0
- package/.idea/misc.xml +6 -0
- package/.idea/modules.xml +8 -0
- package/.idea/vcs.xml +6 -0
- package/LICENSE +21 -0
- package/README.md +584 -0
- package/package.json +13 -0
- package/src/api.mjs +567 -0
- package/src/cli.mjs +241 -0
- package/src/craft.mjs +172 -0
- package/src/db.mjs +93 -0
- package/src/farmhands.mjs +111 -0
- package/src/git.mjs +578 -0
- package/src/identity.mjs +199 -0
- package/src/legacy-defaults.mjs +44 -0
- package/src/mcp.mjs +681 -0
- package/src/mesh.mjs +222 -0
- package/src/packets.mjs +125 -0
- package/src/patches.mjs +141 -0
- package/src/pipelines.mjs +2145 -0
- package/src/pricing.mjs +91 -0
- package/src/projects.mjs +148 -0
- package/src/public/classic.html +1810 -0
- package/src/public/index.html +3284 -0
- package/src/questions.mjs +69 -0
- package/src/quota.mjs +76 -0
- package/src/registry.mjs +88 -0
- package/src/replay.mjs +151 -0
- package/src/rooms.mjs +320 -0
- package/src/server.mjs +765 -0
- package/src/store.mjs +546 -0
- package/src/supervisor.mjs +1803 -0
- package/src/tasks.mjs +191 -0
- package/src/template.mjs +86 -0
- package/src/tools.mjs +241 -0
- package/src/workspaces.mjs +605 -0
- package/src/ws.mjs +130 -0
- package/src/yaml.mjs +266 -0
- package/test/board.mjs +108 -0
- package/test/craft.mjs +281 -0
- package/test/extend.mjs +145 -0
- package/test/flow.mjs +165 -0
- package/test/handoff.mjs +228 -0
- package/test/linked.mjs +83 -0
- package/test/loop.mjs +168 -0
- package/test/mock-opencode.mjs +217 -0
- package/test/parallel.mjs +135 -0
- package/test/patches.mjs +93 -0
- package/test/repos.mjs +118 -0
- package/test/roles.mjs +157 -0
- package/test/shot.mjs +65 -0
- package/test/smoke.mjs +70 -0
- package/test/transport.mjs +131 -0
- package/test/workspaces.mjs +228 -0
- package/test/workspaces.mjs.tmp +0 -0
|
@@ -0,0 +1,1810 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
6
|
+
<title>botfarm</title>
|
|
7
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
8
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
9
|
+
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
|
|
10
|
+
<style>
|
|
11
|
+
:root {
|
|
12
|
+
/* Status is carried by amber / violet / blue, never red-versus-green:
|
|
13
|
+
the one pair a red-green colour blind operator cannot separate. Red is
|
|
14
|
+
used only for failure, where it never has to be told apart from success,
|
|
15
|
+
and every state is also labelled in words. */
|
|
16
|
+
--ink: #0a111c; --panel: #172232; --panel-2: #1f2c3f; --line: #37495f;
|
|
17
|
+
--text: #f0f4fa; --dim: #a3b2c9; --faint: #6c7f9c;
|
|
18
|
+
--busy: #ffab2e; --wait: #b494ff; --error: #ff6f61; --idle: #7d92b0; --ok: #4cc3ff;
|
|
19
|
+
--radius: 5px;
|
|
20
|
+
--mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
21
|
+
--sans: "IBM Plex Sans", ui-sans-serif, system-ui, -apple-system, sans-serif;
|
|
22
|
+
}
|
|
23
|
+
* { box-sizing: border-box; }
|
|
24
|
+
[hidden] { display: none !important; }
|
|
25
|
+
html, body { margin: 0; height: 100%; }
|
|
26
|
+
body { background: var(--ink); color: var(--text); font-family: var(--sans); font-size: 14px; line-height: 1.45; -webkit-font-smoothing: antialiased; }
|
|
27
|
+
body.modal-open { overflow: hidden; }
|
|
28
|
+
.num { font-family: var(--mono); font-variant-numeric: tabular-nums; }
|
|
29
|
+
button, input, textarea, select { font: inherit; color: inherit; background: var(--panel-2); border: 1px solid var(--line); border-radius: var(--radius); padding: 5px 9px; }
|
|
30
|
+
button { cursor: pointer; transition: background .12s, border-color .12s; }
|
|
31
|
+
button:hover { background: #22304a; }
|
|
32
|
+
button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px solid var(--wait); outline-offset: 1px; }
|
|
33
|
+
input::placeholder, textarea::placeholder { color: var(--faint); }
|
|
34
|
+
.ghost { background: transparent; color: var(--dim); }
|
|
35
|
+
.ghost:hover { color: var(--text); }
|
|
36
|
+
.danger:hover { background: #3a1f20; border-color: #6b2f2c; color: #ffb3ab; }
|
|
37
|
+
.primary { background: #24405f; border-color: #33587f; }
|
|
38
|
+
.primary:hover { background: #2c4e74; }
|
|
39
|
+
.tiny { font-size: 11px; padding: 2px 7px; }
|
|
40
|
+
|
|
41
|
+
header { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; gap: 24px; flex-wrap: wrap; padding: 12px 20px; background: var(--ink); border-bottom: 1px solid var(--line); }
|
|
42
|
+
.brand { display: flex; align-items: baseline; gap: 10px; }
|
|
43
|
+
.brand b { font-weight: 600; font-size: 16px; letter-spacing: -.01em; }
|
|
44
|
+
.brand span { color: var(--faint); font-size: 12px; font-family: var(--mono); }
|
|
45
|
+
.readout { display: flex; gap: 20px; align-items: flex-end; }
|
|
46
|
+
.stat { display: flex; flex-direction: column; }
|
|
47
|
+
.stat b { font-family: var(--mono); font-weight: 500; font-size: 17px; letter-spacing: -.02em; }
|
|
48
|
+
.stat i { font-style: normal; font-size: 11px; color: var(--dim); }
|
|
49
|
+
.stat.est b { color: var(--ok); }
|
|
50
|
+
.header-right { margin-left: auto; display: flex; align-items: center; gap: 10px; }
|
|
51
|
+
|
|
52
|
+
.shell { display: grid; grid-template-columns: 228px 1fr; align-items: start; }
|
|
53
|
+
nav { position: sticky; top: 53px; padding: 16px 12px 40px; border-right: 1px solid var(--line); height: calc(100vh - 53px); overflow-y: auto; }
|
|
54
|
+
nav h4 { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--faint); margin: 18px 6px 6px; }
|
|
55
|
+
nav h4:first-child { margin-top: 0; }
|
|
56
|
+
.navitem { display: flex; align-items: center; gap: 8px; width: 100%; border: 0; background: transparent; border-radius: var(--radius); padding: 6px 8px; text-align: left; color: var(--dim); }
|
|
57
|
+
.navitem:hover { background: var(--panel); color: var(--text); }
|
|
58
|
+
.navitem[aria-current="true"] { background: var(--panel-2); color: var(--text); }
|
|
59
|
+
.navitem .c { margin-left: auto; font-family: var(--mono); font-size: 11px; color: var(--faint); }
|
|
60
|
+
.navitem .c b { color: var(--wait); font-weight: 500; }
|
|
61
|
+
.navitem.room .n { font-family: var(--mono); font-size: 12.5px; }
|
|
62
|
+
.dotm { width: 6px; height: 6px; border-radius: 50%; background: var(--wait); display: inline-block; margin-right: 6px; }
|
|
63
|
+
.muted .dotm { background: var(--faint); }
|
|
64
|
+
.navadd { color: var(--faint); font-size: 12px; }
|
|
65
|
+
|
|
66
|
+
main { padding: 16px 20px 60px; min-width: 0; }
|
|
67
|
+
.toolbar { display: flex; gap: 10px; align-items: center; margin-bottom: 14px; flex-wrap: wrap; }
|
|
68
|
+
.toolbar input { width: 250px; }
|
|
69
|
+
.banner { display: flex; gap: 10px; align-items: center; background: var(--panel); border: 1px solid var(--line); border-left: 3px solid var(--busy); border-radius: var(--radius); padding: 9px 12px; margin-bottom: 14px; font-size: 13px; color: var(--dim); }
|
|
70
|
+
.grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); }
|
|
71
|
+
|
|
72
|
+
.pod { display: grid; grid-template-columns: 4px 1fr; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; }
|
|
73
|
+
.rail { background: var(--idle); }
|
|
74
|
+
.pod[data-status="busy"] .rail { background: var(--busy); }
|
|
75
|
+
.pod[data-status="waiting"] .rail { background: var(--wait); }
|
|
76
|
+
.pod[data-status="error"] .rail { background: var(--error); }
|
|
77
|
+
.body { padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; min-width: 0; }
|
|
78
|
+
.row1 { display: flex; align-items: center; gap: 9px; min-width: 0; }
|
|
79
|
+
.avatar { width: 54px; height: 54px; border-radius: 10px; flex: none; cursor: pointer; }
|
|
80
|
+
.who { display: flex; flex-direction: column; min-width: 0; }
|
|
81
|
+
.handle { font-family: var(--mono); font-size: 15px; font-weight: 500; cursor: pointer; }
|
|
82
|
+
.handle:hover { text-decoration: underline; }
|
|
83
|
+
.name { font-size: 12.5px; color: var(--dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
84
|
+
.state { margin-left: auto; font-family: var(--mono); font-size: 11px; color: var(--dim); white-space: nowrap; }
|
|
85
|
+
.pod[data-status="busy"] .state { color: var(--busy); }
|
|
86
|
+
.pod[data-status="waiting"] .state { color: var(--wait); }
|
|
87
|
+
.pod[data-status="error"] .state { color: var(--error); }
|
|
88
|
+
.dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: currentColor; margin-right: 6px; vertical-align: 1px; }
|
|
89
|
+
.pod[data-status="busy"] .dot { animation: pulse 1.6s ease-in-out infinite; }
|
|
90
|
+
@keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: .25 } }
|
|
91
|
+
@media (prefers-reduced-motion: reduce) { .dot { animation: none !important } }
|
|
92
|
+
.meta { display: flex; gap: 8px; font-family: var(--mono); font-size: 11px; color: var(--dim); min-width: 0; flex-wrap: wrap; }
|
|
93
|
+
.meta .path { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
94
|
+
.tag { border: 1px solid var(--line); border-radius: 3px; padding: 0 5px; white-space: nowrap; }
|
|
95
|
+
.tag.diff { color: var(--ok); border-color: #1d4a63; }
|
|
96
|
+
.tag.mail { color: var(--wait); border-color: #3b3159; background: #221b3a; }
|
|
97
|
+
.tag.grp { color: var(--faint); }
|
|
98
|
+
.charts { display: flex; gap: 14px; }
|
|
99
|
+
.chart { flex: 1; min-width: 0; }
|
|
100
|
+
.chart .cap { display: flex; justify-content: space-between; align-items: baseline; font-size: 11px; color: var(--dim); }
|
|
101
|
+
.chart .cap b { font-family: var(--mono); font-weight: 500; font-size: 13px; color: var(--text); }
|
|
102
|
+
svg.spark { display: block; width: 100%; height: 28px; }
|
|
103
|
+
.say { font-size: 12.5px; color: var(--dim); background: var(--ink); border: 1px solid var(--line); border-radius: var(--radius); padding: 7px 9px; min-height: 54px; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
|
104
|
+
.say.tool { color: var(--busy); font-family: var(--mono); font-size: 12px; }
|
|
105
|
+
.actions { display: flex; gap: 6px; }
|
|
106
|
+
.actions input { flex: 1; min-width: 0; font-size: 12.5px; }
|
|
107
|
+
.perm { background: #2a2145; border: 1px solid #453a72; border-radius: var(--radius); padding: 6px 9px; font-size: 12px; display: flex; gap: 8px; align-items: center; }
|
|
108
|
+
.perm b { color: var(--wait); font-weight: 500; }
|
|
109
|
+
|
|
110
|
+
.scrim { position: fixed; inset: 0; background: rgba(6,9,15,.72); z-index: 30; }
|
|
111
|
+
.modal { position: fixed; inset: 4vh 3vw; z-index: 31; display: grid; grid-template-columns: 1fr 340px; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; box-shadow: 0 24px 70px rgba(0,0,0,.5); animation: pop .16s ease-out; }
|
|
112
|
+
@keyframes pop { from { transform: scale(.985); opacity: .5 } }
|
|
113
|
+
@media (prefers-reduced-motion: reduce) { .modal { animation: none } }
|
|
114
|
+
.modal .left { display: flex; flex-direction: column; min-width: 0; }
|
|
115
|
+
.modal .top { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-bottom: 1px solid var(--line); }
|
|
116
|
+
.modal .top .h { font-family: var(--mono); font-size: 15px; font-weight: 500; }
|
|
117
|
+
.modal .top .s { color: var(--dim); font-size: 12px; }
|
|
118
|
+
.modal .top .right { margin-left: auto; display: flex; gap: 6px; }
|
|
119
|
+
.transcript { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 12px; }
|
|
120
|
+
.turn { display: flex; gap: 10px; }
|
|
121
|
+
.turn img, .turn .avdot { width: 40px; height: 40px; border-radius: 8px; flex: none; }
|
|
122
|
+
.turn .avdot { background: #14344a; border: 1px solid #245c7d; }
|
|
123
|
+
.turn .c { min-width: 0; flex: 1; }
|
|
124
|
+
.turn .m { font-family: var(--mono); font-size: 11px; color: var(--dim); margin-bottom: 2px; }
|
|
125
|
+
.turn.user .m, .turn.op .m { color: var(--ok); }
|
|
126
|
+
.turn .b { font-size: 13.5px; white-space: pre-wrap; word-break: break-word; }
|
|
127
|
+
.turn .tools { margin-top: 5px; display: flex; gap: 6px; flex-wrap: wrap; }
|
|
128
|
+
.turn .tools span { font-family: var(--mono); font-size: 11px; color: var(--busy); border: 1px solid #45391f; border-radius: 3px; padding: 0 5px; }
|
|
129
|
+
.turn .b.long { max-height: 9.5em; overflow: hidden; -webkit-mask-image: linear-gradient(#000 70%, transparent); mask-image: linear-gradient(#000 70%, transparent); }
|
|
130
|
+
.turn details.more > summary, .turn details.think > summary { cursor: pointer; font-family: var(--mono); font-size: 11px; color: var(--faint); list-style: none; margin-top: 3px; }
|
|
131
|
+
.turn details.more > summary .lbl { display: block; margin-top: 2px; }
|
|
132
|
+
.turn details.more > summary .less, .turn details.more[open] > summary .b, .turn details.more[open] > summary .lbl:not(.less) { display: none; }
|
|
133
|
+
.turn details.more[open] > summary .less { display: block; }
|
|
134
|
+
.turn details.think { margin: 4px 0; }
|
|
135
|
+
.turn details.think > div { font-size: 12.5px; color: var(--dim); font-style: italic; white-space: pre-wrap; border-left: 2px solid var(--line); padding-left: 8px; margin-top: 3px; }
|
|
136
|
+
.turn .call { display: flex; gap: 8px; align-items: baseline; font-family: var(--mono); font-size: 11.5px; margin: 3px 0; padding: 3px 7px; border: 1px solid var(--line); border-radius: 4px; background: var(--ink); min-width: 0; }
|
|
137
|
+
.turn .call .nm { color: var(--busy); flex: none; }
|
|
138
|
+
.turn .call .sm { color: var(--dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; flex: 1; }
|
|
139
|
+
.turn .call .st { color: var(--faint); flex: none; }
|
|
140
|
+
.turn .call.s-running .st, .turn .call.s-pending .st { color: var(--busy); }
|
|
141
|
+
.turn .call.s-error { border-color: var(--error); }
|
|
142
|
+
.turn .call.s-error .st { color: var(--error); }
|
|
143
|
+
.turn .out { font-family: var(--mono); font-size: 11px; color: var(--dim); white-space: pre-wrap; max-height: 12em; overflow: auto; margin: 0 0 4px 8px; }
|
|
144
|
+
.working { font-family: var(--mono); font-size: 12px; color: var(--busy); padding: 2px 0 2px 50px; }
|
|
145
|
+
.working i { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--busy); margin-right: 6px; animation: pulse 1.2s ease-in-out infinite; }
|
|
146
|
+
@keyframes pulse { 50% { opacity: .25 } }
|
|
147
|
+
@media (prefers-reduced-motion: reduce) { .working i { animation: none } }
|
|
148
|
+
.crew .who .doing { display: block; max-width: 220px; font-size: 11px; color: var(--dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); }
|
|
149
|
+
.composer { display: flex; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--line); }
|
|
150
|
+
.composer textarea { flex: 1; resize: none; min-height: 40px; max-height: 140px; font-size: 13px; }
|
|
151
|
+
.side { border-left: 1px solid var(--line); overflow-y: auto; padding: 0 16px 28px; background: var(--ink); }
|
|
152
|
+
.sec { margin-top: 18px; }
|
|
153
|
+
.sec h3 { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; font-weight: 600; color: var(--faint); margin: 0 0 8px; }
|
|
154
|
+
table.kv { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 12px; }
|
|
155
|
+
table.kv td { padding: 3px 0; border-bottom: 1px dotted var(--line); }
|
|
156
|
+
table.kv td:last-child { text-align: right; }
|
|
157
|
+
.row { display: flex; align-items: center; gap: 8px; padding: 4px 0; border-bottom: 1px dotted var(--line); font-size: 12px; }
|
|
158
|
+
.row .g { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); }
|
|
159
|
+
.row .st { font-size: 10.5px; color: var(--faint); }
|
|
160
|
+
.sw { width: 34px; height: 19px; border-radius: 10px; background: #2a3550; border: 1px solid var(--line); position: relative; padding: 0; flex: none; }
|
|
161
|
+
.sw::after { content: ""; position: absolute; top: 2px; left: 2px; width: 13px; height: 13px; border-radius: 50%; background: var(--faint); transition: left .12s, background .12s; }
|
|
162
|
+
.sw[aria-checked="true"] { background: #24405f; border-color: #33587f; }
|
|
163
|
+
.sw[aria-checked="true"]::after { left: 17px; background: var(--ok); }
|
|
164
|
+
.sw::before { content: "off"; position: absolute; right: -24px; top: 1px; font-size: 10px; color: var(--faint); }
|
|
165
|
+
.sw[aria-checked="true"]::before { content: "on"; color: var(--ok); }
|
|
166
|
+
.seg { display: inline-flex; border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; }
|
|
167
|
+
.seg button { border: 0; border-radius: 0; background: transparent; padding: 4px 9px; font-size: 11.5px; color: var(--dim); }
|
|
168
|
+
.seg button[aria-pressed="true"] { background: #24405f; color: var(--text); }
|
|
169
|
+
.note { font-size: 11.5px; color: var(--faint); margin-top: 6px; }
|
|
170
|
+
.wire { font-family: var(--mono); font-size: 10.5px; color: var(--faint); word-break: break-all; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 5px 7px; margin-top: 6px; }
|
|
171
|
+
.project { display: grid; grid-template-columns: 1fr 340px; gap: 12px; align-items: start; }
|
|
172
|
+
.project > .work { min-width: 0; }
|
|
173
|
+
.chatpane { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); display: flex; flex-direction: column; height: calc(100vh - 150px); position: sticky; top: 66px; }
|
|
174
|
+
.chatpane .ph { padding: 9px 12px; border-bottom: 1px solid var(--line); display: flex; align-items: center; gap: 8px; }
|
|
175
|
+
.chatpane .ph b { font-family: var(--mono); font-size: 13px; font-weight: 500; }
|
|
176
|
+
.chatpane .log { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 10px; }
|
|
177
|
+
.crew { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; }
|
|
178
|
+
.crew .who { display: flex; align-items: center; gap: 8px; background: var(--panel); border: 1px solid var(--line); border-radius: 28px; padding: 4px 14px 4px 5px; font-family: var(--mono); font-size: 12.5px; cursor: pointer; }
|
|
179
|
+
.crew .who img { width: 40px; height: 40px; border-radius: 50%; }
|
|
180
|
+
.crew .who[data-status="busy"] { border-color: var(--busy); color: var(--busy); }
|
|
181
|
+
.crew .who[data-status="waiting"] { border-color: var(--wait); color: var(--wait); }
|
|
182
|
+
.crew .who[data-status="error"] { border-color: var(--error); color: var(--error); }
|
|
183
|
+
.phead { display: flex; align-items: baseline; gap: 10px; margin-bottom: 10px; }
|
|
184
|
+
.phead h2 { margin: 0; font-size: 17px; font-weight: 600; letter-spacing: -.01em; }
|
|
185
|
+
.phead .sub { font-family: var(--mono); font-size: 12px; color: var(--dim); }
|
|
186
|
+
.phead .right { margin-left: auto; display: flex; gap: 6px; }
|
|
187
|
+
.modal .left.split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); overflow: hidden; }
|
|
188
|
+
.modal .left.split > * { min-width: 0; min-height: 0; display: flex; flex-direction: column; }
|
|
189
|
+
.modal .left.split .taskside { overflow-y: auto; border-right: 1px solid var(--line); display: block; padding-bottom: 20px; }
|
|
190
|
+
.modal .left.split .taskside .sec { padding: 0 16px; }
|
|
191
|
+
.ask { background: #2b2350; border: 1px solid #4a3d84; border-left: 3px solid var(--wait); border-radius: var(--radius); padding: 11px 13px; margin-bottom: 10px; }
|
|
192
|
+
.ask .q { font-size: 13.5px; margin-bottom: 4px; }
|
|
193
|
+
.ask .asker { font-family: var(--mono); font-size: 11px; color: var(--wait); margin-bottom: 6px; display: flex; align-items: center; gap: 7px; }
|
|
194
|
+
.ask .asker img { width: 26px; height: 26px; border-radius: 6px; }
|
|
195
|
+
.ask .opts { display: flex; gap: 6px; flex-wrap: wrap; margin: 8px 0; }
|
|
196
|
+
.ask .reply { display: flex; gap: 6px; }
|
|
197
|
+
.ask .reply input { flex: 1; }
|
|
198
|
+
.col.waiting h5 { color: var(--wait); }
|
|
199
|
+
.card.question { border-left: 3px solid var(--wait); }
|
|
200
|
+
.editor { display: grid; gap: 10px; }
|
|
201
|
+
.stagebox { background: var(--panel-2); border: 1px solid var(--line); border-radius: var(--radius); padding: 10px 12px; }
|
|
202
|
+
.stagebox .hd { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; }
|
|
203
|
+
.stagebox .hd select { max-width: 180px; }
|
|
204
|
+
.stagebox .hd input.sid { width: 130px; font-family: var(--mono); }
|
|
205
|
+
.stagebox .hd .sp { margin-left: auto; display: flex; gap: 4px; }
|
|
206
|
+
.stagebox label { display: block; font-size: 11px; color: var(--faint); margin: 8px 0 3px; }
|
|
207
|
+
.stagebox textarea { width: 100%; min-height: 80px; font-family: var(--mono); font-size: 12px; resize: vertical; }
|
|
208
|
+
.stagebox .recv { display: flex; gap: 8px; flex-wrap: wrap; font-size: 11.5px; color: var(--dim); }
|
|
209
|
+
.stagebox .recv label { display: inline-flex; align-items: center; gap: 4px; margin: 0; color: var(--dim); font-size: 11.5px; }
|
|
210
|
+
.kanban { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(230px, 1fr); gap: 10px; align-items: start; overflow-x: auto; padding-bottom: 8px; }
|
|
211
|
+
.col { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); min-height: 90px; }
|
|
212
|
+
.col h5 { margin: 0; padding: 8px 10px; font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--faint); border-bottom: 1px solid var(--line); display: flex; }
|
|
213
|
+
.col h5 span { margin-left: auto; font-family: var(--mono); }
|
|
214
|
+
.col.active h5 { color: var(--busy); }
|
|
215
|
+
.col.review h5 { color: var(--wait); }
|
|
216
|
+
.col.done h5 { color: var(--ok); }
|
|
217
|
+
.col.cancelled h5 { color: var(--error); }
|
|
218
|
+
.card { padding: 8px 10px; border-bottom: 1px dotted var(--line); cursor: pointer; }
|
|
219
|
+
.card:hover { background: var(--panel-2); }
|
|
220
|
+
.card .t { font-size: 12.5px; }
|
|
221
|
+
.card .s { display: flex; gap: 7px; align-items: center; margin-top: 6px; font-family: var(--mono); font-size: 11px; color: var(--faint); }
|
|
222
|
+
.card .s img { width: 24px; height: 24px; border-radius: 5px; }
|
|
223
|
+
.card .s .stage { color: var(--wait); }
|
|
224
|
+
.runs { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 14px; }
|
|
225
|
+
.run { background: var(--panel); border: 1px solid var(--line); border-left: 3px solid var(--ok); border-radius: var(--radius); padding: 8px 11px; min-width: 240px; }
|
|
226
|
+
.run .rt { font-size: 12.5px; }
|
|
227
|
+
.run .flow { display: flex; gap: 4px; align-items: center; margin-top: 6px; font-family: var(--mono); font-size: 10.5px; }
|
|
228
|
+
.run .step { padding: 1px 6px; border-radius: 3px; border: 1px solid var(--line); color: var(--faint); }
|
|
229
|
+
.run .step.done { color: var(--ok); border-color: #1d4a63; }
|
|
230
|
+
.run .step.active { color: var(--busy); border-color: #45391f; }
|
|
231
|
+
.run .step.review { color: var(--wait); border-color: #3b3159; }
|
|
232
|
+
.run .arrow { color: var(--faint); }
|
|
233
|
+
.diff { font-family: var(--mono); font-size: 11.5px; white-space: pre; overflow-x: auto; background: var(--ink); border: 1px solid var(--line); border-radius: var(--radius); padding: 8px; max-height: 50vh; overflow-y: auto; }
|
|
234
|
+
/* +/- prefixes do the work; colour only reinforces it. */
|
|
235
|
+
.diff .add { color: var(--ok); background: rgba(76,195,255,.07); }
|
|
236
|
+
.diff .del { color: var(--busy); background: rgba(255,171,46,.07); }
|
|
237
|
+
.diff .hunk { color: var(--wait); }
|
|
238
|
+
.empty { color: var(--dim); padding: 50px 0; text-align: center; }
|
|
239
|
+
.empty b { display: block; color: var(--text); font-size: 16px; margin-bottom: 6px; font-weight: 600; }
|
|
240
|
+
dialog { background: var(--panel); color: var(--text); border: 1px solid var(--line); border-radius: 8px; padding: 20px; width: min(560px, 94vw); }
|
|
241
|
+
dialog::backdrop { background: rgba(6,9,15,.65); }
|
|
242
|
+
dialog label { display: block; font-size: 12px; color: var(--dim); margin: 12px 0 4px; }
|
|
243
|
+
dialog input, dialog textarea, dialog select { width: 100%; }
|
|
244
|
+
.pick { max-height: 290px; overflow-y: auto; border: 1px solid var(--line); border-radius: var(--radius); margin-top: 6px; }
|
|
245
|
+
.pick label { display: flex; gap: 9px; align-items: center; margin: 0; padding: 6px 9px; color: var(--text); border-bottom: 1px dotted var(--line); font-size: 12.5px; }
|
|
246
|
+
.pick label:hover { background: var(--panel-2); }
|
|
247
|
+
.pick input { width: auto; }
|
|
248
|
+
.pick .d { color: var(--faint); font-family: var(--mono); font-size: 11px; margin-left: auto; white-space: nowrap; }
|
|
249
|
+
.feed { font-family: var(--mono); font-size: 11.5px; color: var(--faint); max-height: 150px; overflow-y: auto; }
|
|
250
|
+
.feed .warn { color: var(--busy); }
|
|
251
|
+
.feed .error { color: var(--error); }
|
|
252
|
+
@media (max-width: 1200px) {
|
|
253
|
+
.project { grid-template-columns: 1fr; }
|
|
254
|
+
.chatpane { height: 420px; position: static; }
|
|
255
|
+
.split { grid-template-columns: 1fr; }
|
|
256
|
+
}
|
|
257
|
+
@media (max-width: 900px) {
|
|
258
|
+
.shell { grid-template-columns: 1fr; }
|
|
259
|
+
nav { position: static; height: auto; border-right: 0; border-bottom: 1px solid var(--line); }
|
|
260
|
+
.modal { inset: 0; border-radius: 0; grid-template-columns: 1fr; }
|
|
261
|
+
.side { display: none; }
|
|
262
|
+
.grid { grid-template-columns: 1fr; }
|
|
263
|
+
}
|
|
264
|
+
</style>
|
|
265
|
+
</head>
|
|
266
|
+
<body>
|
|
267
|
+
<header>
|
|
268
|
+
<div class="brand"><b>botfarm</b><span id="conn">connecting…</span></div>
|
|
269
|
+
<div class="readout">
|
|
270
|
+
<div class="stat"><b class="num" id="s-busy">0</b><i>running</i></div>
|
|
271
|
+
<div class="stat"><b class="num" id="s-sessions">0</b><i>on the board</i></div>
|
|
272
|
+
<div class="stat"><b class="num" id="s-tpm">0</b><i>tokens/min</i></div>
|
|
273
|
+
<div class="stat"><b class="num" id="s-tools">0</b><i>tool calls</i></div>
|
|
274
|
+
<div class="stat est"><b class="num" id="s-cost">$0.00</b><i id="s-cost-l">est. spend</i></div>
|
|
275
|
+
</div>
|
|
276
|
+
<div class="header-right">
|
|
277
|
+
<svg class="spark" id="global-spark" style="width:170px"></svg>
|
|
278
|
+
<button class="ghost" id="add-existing">Add existing…</button>
|
|
279
|
+
<button class="primary" id="new-pod">New session</button>
|
|
280
|
+
</div>
|
|
281
|
+
</header>
|
|
282
|
+
|
|
283
|
+
<div class="shell">
|
|
284
|
+
<nav>
|
|
285
|
+
<h4>Sessions</h4>
|
|
286
|
+
<div id="nav-views"></div>
|
|
287
|
+
<div id="nav-work"></div>
|
|
288
|
+
<h4>Projects</h4>
|
|
289
|
+
<div id="nav-projects"></div>
|
|
290
|
+
<button class="navitem navadd" id="add-project">+ New project</button>
|
|
291
|
+
</nav>
|
|
292
|
+
|
|
293
|
+
<main>
|
|
294
|
+
<div class="banner" id="loose" hidden></div>
|
|
295
|
+
<div class="toolbar">
|
|
296
|
+
<input id="filter" placeholder="Filter by handle, branch or path /" />
|
|
297
|
+
<span class="num" style="color:var(--faint);font-size:12px" id="scope"></span>
|
|
298
|
+
<button class="ghost" id="new-task">New task</button>
|
|
299
|
+
<button class="ghost danger" id="abort-all" style="margin-left:auto">Abort all</button>
|
|
300
|
+
<span style="color:var(--faint);font-size:12px" id="updated"></span>
|
|
301
|
+
</div>
|
|
302
|
+
<div id="project-view" hidden></div>
|
|
303
|
+
<div class="runs" id="runs"></div>
|
|
304
|
+
<div class="kanban" id="kanban" hidden></div>
|
|
305
|
+
<div class="grid" id="grid"></div>
|
|
306
|
+
<div class="empty" id="empty" hidden>
|
|
307
|
+
<b>Nothing on the board</b>
|
|
308
|
+
Start one with “New session”, or use “Add existing…” to put a session opencode already knows about here.
|
|
309
|
+
</div>
|
|
310
|
+
<div class="sec"><h3>Activity</h3><div class="feed" id="feed"></div></div>
|
|
311
|
+
</main>
|
|
312
|
+
</div>
|
|
313
|
+
|
|
314
|
+
<dialog id="adopt-dialog">
|
|
315
|
+
<b>Add existing sessions</b>
|
|
316
|
+
<div class="note">opencode keeps every session you have ever opened. Pick the ones you are actually running; the rest stay out of the way.</div>
|
|
317
|
+
<label for="a-search">Search</label>
|
|
318
|
+
<input id="a-search" placeholder="title or path" />
|
|
319
|
+
<div class="pick" id="a-list"></div>
|
|
320
|
+
<label for="a-group">Group</label>
|
|
321
|
+
<select id="a-group"></select>
|
|
322
|
+
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px">
|
|
323
|
+
<button class="ghost" id="a-cancel">Cancel</button>
|
|
324
|
+
<button class="primary" id="a-add">Add selected</button>
|
|
325
|
+
</div>
|
|
326
|
+
</dialog>
|
|
327
|
+
|
|
328
|
+
<dialog id="new-dialog">
|
|
329
|
+
<b>New session</b>
|
|
330
|
+
<label for="f-repo">Repository path</label>
|
|
331
|
+
<input id="f-repo" placeholder="~/code/app" />
|
|
332
|
+
<div class="note" id="f-repo-info"></div>
|
|
333
|
+
<div class="pick" id="f-services" hidden></div>
|
|
334
|
+
<label for="f-branch">Branch — creates a git worktree; leave empty to work in the repo itself</label>
|
|
335
|
+
<input id="f-branch" placeholder="fix/auth-redirect" />
|
|
336
|
+
<label for="f-task">First instruction (optional)</label>
|
|
337
|
+
<textarea id="f-task" rows="3"></textarea>
|
|
338
|
+
<label for="f-group">Group</label>
|
|
339
|
+
<select id="f-group"></select>
|
|
340
|
+
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px">
|
|
341
|
+
<button class="ghost" id="f-cancel">Cancel</button>
|
|
342
|
+
<button class="primary" id="f-create">Create</button>
|
|
343
|
+
</div>
|
|
344
|
+
<div id="f-error" style="color:var(--error);font-size:12px;margin-top:10px"></div>
|
|
345
|
+
</dialog>
|
|
346
|
+
|
|
347
|
+
<dialog id="task-dialog">
|
|
348
|
+
<b>New task</b>
|
|
349
|
+
<div class="note">The same thing a bot does with botfarm_task_create — assigned work is offered to a session when it next goes idle.</div>
|
|
350
|
+
<label for="t-title">Title</label>
|
|
351
|
+
<input id="t-title" placeholder="Fix the double-redirect on refresh" />
|
|
352
|
+
<label for="t-brief">Brief — the assignee cannot see your other conversations</label>
|
|
353
|
+
<textarea id="t-brief" rows="4"></textarea>
|
|
354
|
+
<label for="t-project">Project</label>
|
|
355
|
+
<select id="t-project"></select>
|
|
356
|
+
<label for="t-assignee">Assign to</label>
|
|
357
|
+
<select id="t-assignee"></select>
|
|
358
|
+
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px">
|
|
359
|
+
<button class="ghost" id="t-cancel">Cancel</button>
|
|
360
|
+
<button class="primary" id="t-create">Add to board</button>
|
|
361
|
+
</div>
|
|
362
|
+
<div id="t-error" style="color:var(--error);font-size:12px;margin-top:10px"></div>
|
|
363
|
+
</dialog>
|
|
364
|
+
|
|
365
|
+
<dialog id="project-dialog">
|
|
366
|
+
<b>New project</b>
|
|
367
|
+
<div class="note">A project owns a checkout, the sessions working in it, its board and its chat.</div>
|
|
368
|
+
<label for="pj-name">Name</label>
|
|
369
|
+
<input id="pj-name" placeholder="SSO return path" />
|
|
370
|
+
<label for="pj-repo">Repository path (optional)</label>
|
|
371
|
+
<input id="pj-repo" placeholder="~/workspace/app" />
|
|
372
|
+
<label>Sessions to move into it</label>
|
|
373
|
+
<div class="pick" id="pj-sessions"></div>
|
|
374
|
+
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px">
|
|
375
|
+
<button class="ghost" id="pj-cancel">Cancel</button>
|
|
376
|
+
<button class="primary" id="pj-create">Create</button>
|
|
377
|
+
</div>
|
|
378
|
+
<div id="pj-error" style="color:var(--error);font-size:12px;margin-top:10px"></div>
|
|
379
|
+
</dialog>
|
|
380
|
+
|
|
381
|
+
<dialog id="edit-dialog" style="width:min(860px,96vw)">
|
|
382
|
+
<div style="display:flex;align-items:baseline;gap:10px">
|
|
383
|
+
<b id="ed-title">Edit pipeline</b>
|
|
384
|
+
<span class="note" style="margin:0" id="ed-file"></span>
|
|
385
|
+
<span style="margin-left:auto;display:flex;gap:6px">
|
|
386
|
+
<button class="ghost tiny" id="ed-yaml-toggle">Edit as YAML</button>
|
|
387
|
+
<button class="ghost tiny" id="ed-cancel">Close</button>
|
|
388
|
+
<button class="primary tiny" id="ed-save">Save</button>
|
|
389
|
+
</span>
|
|
390
|
+
</div>
|
|
391
|
+
<div id="ed-body" class="editor" style="max-height:70vh;overflow-y:auto;margin-top:12px"></div>
|
|
392
|
+
<textarea id="ed-yaml" hidden style="width:100%;height:60vh;font-family:var(--mono);font-size:12px;margin-top:12px"></textarea>
|
|
393
|
+
<div id="ed-error" style="color:var(--error);font-size:12px;margin-top:10px;white-space:pre-wrap"></div>
|
|
394
|
+
</dialog>
|
|
395
|
+
|
|
396
|
+
<dialog id="pipe-dialog">
|
|
397
|
+
<b>Run a pipeline</b>
|
|
398
|
+
<div class="note">Every persona in the pipeline gets its own session, all sharing one worktree, plus a group chat.</div>
|
|
399
|
+
<label for="p-pipeline">Pipeline</label>
|
|
400
|
+
<select id="p-pipeline"></select>
|
|
401
|
+
<div class="note" id="p-stages"></div>
|
|
402
|
+
<div style="display:flex;gap:6px;margin-top:6px">
|
|
403
|
+
<button class="ghost tiny" id="p-edit">Edit this pipeline</button>
|
|
404
|
+
<button class="ghost tiny" id="p-personas">Edit personas</button>
|
|
405
|
+
</div>
|
|
406
|
+
<label for="p-repo">Repository path</label>
|
|
407
|
+
<input id="p-repo" placeholder="~/code/app" />
|
|
408
|
+
<div class="note" id="p-repo-info"></div>
|
|
409
|
+
<div class="pick" id="p-services" hidden></div>
|
|
410
|
+
<label for="p-branch">Branch for the worktree</label>
|
|
411
|
+
<input id="p-branch" placeholder="feat/sso-return-path" />
|
|
412
|
+
<label for="p-story">Story</label>
|
|
413
|
+
<textarea id="p-story" rows="4" placeholder="Users bounce to the dashboard root after SSO instead of their last page."></textarea>
|
|
414
|
+
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px">
|
|
415
|
+
<button class="ghost" id="p-reload">Reload YAML</button>
|
|
416
|
+
<button class="ghost" id="p-cancel">Cancel</button>
|
|
417
|
+
<button class="primary" id="p-run">Start</button>
|
|
418
|
+
</div>
|
|
419
|
+
<div id="p-error" style="color:var(--error);font-size:12px;margin-top:10px"></div>
|
|
420
|
+
</dialog>
|
|
421
|
+
|
|
422
|
+
<script>
|
|
423
|
+
const $ = (s, r = document) => r.querySelector(s)
|
|
424
|
+
const $$ = (s, r = document) => [...r.querySelectorAll(s)]
|
|
425
|
+
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]))
|
|
426
|
+
|
|
427
|
+
const fmt = {
|
|
428
|
+
n(v) {
|
|
429
|
+
if (!v) return "0"
|
|
430
|
+
if (v >= 1e9) return (v / 1e9).toFixed(1) + "B"
|
|
431
|
+
if (v >= 1e6) return (v / 1e6).toFixed(1) + "M"
|
|
432
|
+
if (v >= 1e3) return (v / 1e3).toFixed(v >= 1e4 ? 0 : 1) + "k"
|
|
433
|
+
return String(Math.round(v))
|
|
434
|
+
},
|
|
435
|
+
money(v) { return "$" + (v ?? 0).toFixed(v && v < 1 ? 3 : 2) },
|
|
436
|
+
ago(ts) {
|
|
437
|
+
if (!ts) return "—"
|
|
438
|
+
const s = Math.max(0, Math.round((Date.now() - ts) / 1000))
|
|
439
|
+
if (s < 60) return s + "s"
|
|
440
|
+
if (s < 3600) return Math.floor(s / 60) + "m" + String(s % 60).padStart(2, "0") + "s"
|
|
441
|
+
if (s < 86400) return Math.floor(s / 3600) + "h" + String(Math.floor((s % 3600) / 60)).padStart(2, "0") + "m"
|
|
442
|
+
return Math.floor(s / 86400) + "d"
|
|
443
|
+
},
|
|
444
|
+
ms(v) { return v == null ? "" : v < 1000 ? v + "ms" : (v / 1000).toFixed(1) + "s" },
|
|
445
|
+
path(p, keep = 3) {
|
|
446
|
+
const short = (p ?? "").replace(/^\/(Users|home)\/[^/]+/, "~")
|
|
447
|
+
const seg = short.split("/")
|
|
448
|
+
return seg.length > keep + 1 ? "…/" + seg.slice(-keep).join("/") : short
|
|
449
|
+
},
|
|
450
|
+
}
|
|
451
|
+
const COLOR = { busy: "#f0a437", waiting: "#a888f4", error: "#e2564a", idle: "#55698c" }
|
|
452
|
+
const avatarUrl = (s) => `/avatar/${encodeURIComponent(s.id)}.svg?h=${encodeURIComponent(s.handle ?? "")}`
|
|
453
|
+
|
|
454
|
+
function spark(svg, values, color, { w = 240, h = 28 } = {}) {
|
|
455
|
+
const n = values.length
|
|
456
|
+
const peak = Math.max(...values)
|
|
457
|
+
svg.setAttribute("viewBox", `0 0 ${w} ${h}`)
|
|
458
|
+
svg.setAttribute("preserveAspectRatio", "none")
|
|
459
|
+
if (!peak) { svg.innerHTML = ""; return }
|
|
460
|
+
const dx = w / (n - 1)
|
|
461
|
+
const y = (v) => h - 1.5 - (v / peak) * (h - 4)
|
|
462
|
+
let d = ""
|
|
463
|
+
values.forEach((v, i) => { d += (i ? "L" : "M") + (i * dx).toFixed(1) + "," + y(v).toFixed(1) })
|
|
464
|
+
const id = "g" + Math.abs([...color].reduce((a, ch) => (a * 31 + ch.charCodeAt(0)) | 0, 7))
|
|
465
|
+
svg.innerHTML =
|
|
466
|
+
`<defs><linearGradient id="${id}" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stop-color="${color}" stop-opacity=".38"/><stop offset="1" stop-color="${color}" stop-opacity="0"/></linearGradient></defs>` +
|
|
467
|
+
`<path d="${d}L${w},${h}L0,${h}Z" fill="url(#${id})"/>` +
|
|
468
|
+
`<path d="${d}" fill="none" stroke="${color}" stroke-width="1.4" stroke-linejoin="round" vector-effect="non-scaling-stroke"/>`
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async function api(path, opts = {}) {
|
|
472
|
+
const res = await fetch(path, {
|
|
473
|
+
method: opts.method ?? "GET",
|
|
474
|
+
headers: opts.body ? { "content-type": "application/json" } : undefined,
|
|
475
|
+
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
476
|
+
})
|
|
477
|
+
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? res.statusText)
|
|
478
|
+
return res.status === 204 ? null : res.json()
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
let state = { at: Date.now(), sessions: [], rooms: [], groups: [], totals: {}, events: [] }
|
|
482
|
+
let view = { kind: "all", value: null }
|
|
483
|
+
let filterText = ""
|
|
484
|
+
let openId = null
|
|
485
|
+
// Live transcript for the open session, fed by websocket frames.
|
|
486
|
+
let live = { id: null, msgs: null, status: null, currentTool: null, at: 0 }
|
|
487
|
+
const openThoughts = new Set()
|
|
488
|
+
let liveFrame = 0
|
|
489
|
+
let sock = null
|
|
490
|
+
const wsSend = (text) => { if (sock?.readyState === 1) sock.send(text) }
|
|
491
|
+
let roomId = null
|
|
492
|
+
let modalSig = null
|
|
493
|
+
let roomSig = null
|
|
494
|
+
let navSig = null
|
|
495
|
+
|
|
496
|
+
// ------------------------------------------------------------------ cards
|
|
497
|
+
const pods = new Map()
|
|
498
|
+
|
|
499
|
+
function podEl(s) {
|
|
500
|
+
const el = document.createElement("div")
|
|
501
|
+
el.className = "pod"
|
|
502
|
+
el.innerHTML = `
|
|
503
|
+
<div class="rail"></div>
|
|
504
|
+
<div class="body">
|
|
505
|
+
<div class="row1">
|
|
506
|
+
<img class="avatar" alt="" />
|
|
507
|
+
<span class="who"><span class="handle"></span><span class="name"></span></span>
|
|
508
|
+
<span class="state"><i class="dot"></i><em class="stxt"></em></span>
|
|
509
|
+
</div>
|
|
510
|
+
<div class="meta"><span class="path"></span><span class="tag branch"></span><span class="tag diff"></span>
|
|
511
|
+
<span class="tag ctx"></span><span class="tag cost"></span><span class="tag mail"></span><span class="tag grp"></span></div>
|
|
512
|
+
<div class="charts">
|
|
513
|
+
<div class="chart"><div class="cap"><span>tokens/min</span><b class="tpm">0</b></div><svg class="spark s-tok"></svg></div>
|
|
514
|
+
<div class="chart"><div class="cap"><span>tools/min</span><b class="tlm">0</b></div><svg class="spark s-tool"></svg></div>
|
|
515
|
+
</div>
|
|
516
|
+
<div class="say"></div>
|
|
517
|
+
<div class="perm" hidden><b>Waiting for permission</b>
|
|
518
|
+
<button class="allow" style="margin-left:auto">Allow once</button><button class="always">Always</button><button class="deny danger">Deny</button></div>
|
|
519
|
+
<div class="actions">
|
|
520
|
+
<input class="msg-in" placeholder="Continue… (⏎ to send)" />
|
|
521
|
+
<button class="send">Send</button>
|
|
522
|
+
<button class="abort danger">Abort</button>
|
|
523
|
+
<button class="open ghost">Open</button>
|
|
524
|
+
</div>
|
|
525
|
+
</div>`
|
|
526
|
+
const r = {
|
|
527
|
+
el, avatar: $(".avatar", el), handle: $(".handle", el), name: $(".name", el), stxt: $(".stxt", el),
|
|
528
|
+
path: $(".path", el), branch: $(".branch", el), diff: $(".diff", el), ctx: $(".ctx", el),
|
|
529
|
+
cost: $(".cost", el), mail: $(".mail", el), grp: $(".grp", el),
|
|
530
|
+
tpm: $(".tpm", el), tlm: $(".tlm", el), sTok: $(".s-tok", el), sTool: $(".s-tool", el),
|
|
531
|
+
say: $(".say", el), perm: $(".perm", el), input: $(".msg-in", el),
|
|
532
|
+
}
|
|
533
|
+
const send = async () => {
|
|
534
|
+
const text = r.input.value.trim()
|
|
535
|
+
if (!text) return
|
|
536
|
+
r.input.value = ""
|
|
537
|
+
try { await api(`/api/sessions/${s.id}/send`, { method: "POST", body: { text } }) }
|
|
538
|
+
catch (e) { r.input.value = text; alert(e.message) }
|
|
539
|
+
}
|
|
540
|
+
$(".send", el).onclick = send
|
|
541
|
+
r.input.onkeydown = (e) => { if (e.key === "Enter") send() }
|
|
542
|
+
$(".abort", el).onclick = () => api(`/api/sessions/${s.id}/abort`, { method: "POST" }).catch((e) => alert(e.message))
|
|
543
|
+
$(".open", el).onclick = () => openSession(s.id)
|
|
544
|
+
r.handle.onclick = () => openSession(s.id)
|
|
545
|
+
r.avatar.onclick = () => openSession(s.id)
|
|
546
|
+
for (const [cls, d] of [[".allow", "once"], [".always", "always"], [".deny", "reject"]]) {
|
|
547
|
+
$(cls, el).onclick = () => replyPermission(s.id, d)
|
|
548
|
+
}
|
|
549
|
+
return r
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function updatePod(r, s) {
|
|
553
|
+
r.el.dataset.status = s.status
|
|
554
|
+
r.avatar.src = avatarUrl(s)
|
|
555
|
+
r.handle.textContent = s.handle ?? "…"
|
|
556
|
+
r.name.textContent = s.name
|
|
557
|
+
r.name.title = s.id
|
|
558
|
+
r.stxt.textContent =
|
|
559
|
+
s.status === "busy" ? "running " + fmt.ago(s.busySince ?? s.updated)
|
|
560
|
+
: s.status === "waiting" ? "needs input"
|
|
561
|
+
: s.status === "error" ? (s.lastError ?? "error")
|
|
562
|
+
: "idle " + fmt.ago(s.updated)
|
|
563
|
+
r.path.textContent = fmt.path(s.directory) || "—"
|
|
564
|
+
r.path.title = s.directory ?? ""
|
|
565
|
+
r.branch.hidden = !s.branch
|
|
566
|
+
r.branch.textContent = s.branch ?? ""
|
|
567
|
+
const g = s.git
|
|
568
|
+
r.diff.hidden = !g || (!g.insertions && !g.deletions && !g.dirty)
|
|
569
|
+
if (!r.diff.hidden) r.diff.textContent = `+${g.insertions} −${g.deletions} · ${g.dirty} files`
|
|
570
|
+
r.ctx.hidden = !s.contextTokens
|
|
571
|
+
r.ctx.textContent = fmt.n(s.contextTokens) + " ctx"
|
|
572
|
+
r.cost.hidden = !s.cost?.usd
|
|
573
|
+
if (s.cost?.usd) {
|
|
574
|
+
r.cost.textContent = (s.cost.estimated ? "~" : "") + fmt.money(s.cost.usd)
|
|
575
|
+
r.cost.title = s.cost.estimated ? "estimated from token counts" : "reported by the provider"
|
|
576
|
+
}
|
|
577
|
+
r.mail.hidden = !s.unread
|
|
578
|
+
if (s.unread) r.mail.textContent = `${s.unread} from @${s.lastPeer ?? "?"}`
|
|
579
|
+
r.grp.hidden = !s.project || s.project === "default"
|
|
580
|
+
r.grp.textContent = s.project ?? ""
|
|
581
|
+
r.tpm.textContent = fmt.n(s.tokPerMin)
|
|
582
|
+
r.tlm.textContent = s.toolPerMin ?? 0
|
|
583
|
+
spark(r.sTok, s.spark.tok, COLOR[s.status] ?? COLOR.idle)
|
|
584
|
+
spark(r.sTool, s.spark.tool, s.status === "busy" ? "#6fa8dc" : COLOR.idle)
|
|
585
|
+
if (s.currentTool) {
|
|
586
|
+
r.say.className = "say tool"
|
|
587
|
+
r.say.textContent = `${s.currentTool.name} · ${fmt.ago(s.currentTool.since)}`
|
|
588
|
+
} else {
|
|
589
|
+
r.say.className = "say"
|
|
590
|
+
r.say.textContent = s.lastText || "No messages yet."
|
|
591
|
+
}
|
|
592
|
+
r.perm.hidden = !s.pendingPermissions
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
async function replyPermission(id, decision) {
|
|
596
|
+
const d = await api(`/api/sessions/${id}`)
|
|
597
|
+
const req = d.permissions?.[0]
|
|
598
|
+
if (!req) return
|
|
599
|
+
await api(`/api/sessions/${id}/permission`, { method: "POST", body: { requestId: req.id, decision } })
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// ------------------------------------------------------------------ render
|
|
603
|
+
function visibleSessions() {
|
|
604
|
+
return state.sessions.filter((s) => {
|
|
605
|
+
if (view.kind === "running" && s.status === "idle") return false
|
|
606
|
+
if (view.kind === "project" && (s.project ?? "default") !== view.value) return false
|
|
607
|
+
if (view.kind === "room") {
|
|
608
|
+
const room = state.rooms.find((r) => r.id === view.value)
|
|
609
|
+
if (!room || !room.members.some((m) => m.id === s.id)) return false
|
|
610
|
+
}
|
|
611
|
+
if (!filterText) return true
|
|
612
|
+
return [s.handle, s.name, s.directory, s.branch, s.model, s.project].join(" ").toLowerCase().includes(filterText)
|
|
613
|
+
})
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function renderNav() {
|
|
617
|
+
const rooms = state.rooms ?? []
|
|
618
|
+
const projects = state.projects ?? []
|
|
619
|
+
const sig = JSON.stringify([view, projects, state.totals.busy, state.sessions.length, (state.tasks ?? []).map((t) => t.status)])
|
|
620
|
+
if (sig === navSig) return
|
|
621
|
+
navSig = sig
|
|
622
|
+
const item = (kind, value, label, count, cls = "") =>
|
|
623
|
+
`<button class="navitem ${cls}" data-k="${kind}" data-v="${value ?? ""}" aria-current="${view.kind === kind && String(view.value ?? "") === String(value ?? "")}">
|
|
624
|
+
<span class="n">${label}</span><span class="c">${count}</span></button>`
|
|
625
|
+
$("#nav-views").innerHTML =
|
|
626
|
+
item("all", null, "All", state.sessions.length) + item("running", null, "Running", state.totals.busy ?? 0)
|
|
627
|
+
const open = (state.tasks ?? []).filter((t) => !["done", "cancelled"].includes(t.status)).length
|
|
628
|
+
const mine = (state.tasks ?? []).filter((t) => t.owner === "human" && !["done", "cancelled"].includes(t.status))
|
|
629
|
+
$("#nav-work").innerHTML =
|
|
630
|
+
item("needs", null, `Needs you`, mine.length ? `<b>${mine.length}</b>` : "") +
|
|
631
|
+
item("board", null, "Task board", open) +
|
|
632
|
+
`<button class="navitem navadd" id="run-pipeline">▸ Run a pipeline</button>`
|
|
633
|
+
$("#run-pipeline").onclick = openPipelineDialog
|
|
634
|
+
// One row per project: its work and its conversation are the same thing.
|
|
635
|
+
$("#nav-projects").innerHTML = projects
|
|
636
|
+
.map((p) =>
|
|
637
|
+
item(
|
|
638
|
+
"project",
|
|
639
|
+
p.id,
|
|
640
|
+
`${p.busy ? `<span class="dotm" style="background:var(--busy)"></span>` : ""}${esc(p.name)}`,
|
|
641
|
+
`${p.unread ? `<b>${p.unread}✉</b> ` : ""}${p.open || ""}`,
|
|
642
|
+
),
|
|
643
|
+
)
|
|
644
|
+
.join("")
|
|
645
|
+
for (const b of $$(".navitem[data-k]")) {
|
|
646
|
+
b.onclick = () => {
|
|
647
|
+
view = { kind: b.dataset.k, value: b.dataset.v || null }
|
|
648
|
+
navSig = null
|
|
649
|
+
render()
|
|
650
|
+
if (b.dataset.k === "room") openRoom(b.dataset.v)
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function render(fromStream = false) {
|
|
656
|
+
const t = state.totals ?? {}
|
|
657
|
+
$("#s-busy").textContent = t.busy ?? 0
|
|
658
|
+
$("#s-sessions").textContent = t.sessions ?? 0
|
|
659
|
+
$("#s-tpm").textContent = fmt.n(t.tokPerMin)
|
|
660
|
+
$("#s-tools").textContent = fmt.n(t.tools)
|
|
661
|
+
$("#s-cost").textContent = fmt.money(t.usd)
|
|
662
|
+
$("#s-cost-l").textContent = t.unpriced ? `est. spend · ${t.unpriced} unpriced` : "est. spend"
|
|
663
|
+
if (t.spark) spark($("#global-spark"), t.spark.tok, t.busy ? COLOR.busy : COLOR.idle, { w: 170 })
|
|
664
|
+
$("#updated").textContent = new Date(state.at).toLocaleTimeString()
|
|
665
|
+
$("#conn").textContent = state.serverStatus ?? ""
|
|
666
|
+
|
|
667
|
+
const loose = $("#loose")
|
|
668
|
+
loose.hidden = !state.unmanaged
|
|
669
|
+
if (state.unmanaged && !loose.dataset.n) {
|
|
670
|
+
loose.dataset.n = state.unmanaged
|
|
671
|
+
loose.innerHTML = `<span>opencode knows about <b class="num">${state.unmanaged}</b> other session(s) botfarm is not managing.</span>
|
|
672
|
+
<button class="ghost tiny" id="loose-add" style="margin-left:auto">Add some…</button>`
|
|
673
|
+
$("#loose-add").onclick = openAdopt
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
renderRuns()
|
|
677
|
+
const needsView = view.kind === "needs"
|
|
678
|
+
const boardView = view.kind === "board"
|
|
679
|
+
const projectView = view.kind === "project"
|
|
680
|
+
$("#kanban").hidden = !boardView
|
|
681
|
+
$("#grid").hidden = boardView || projectView || needsView
|
|
682
|
+
$("#project-view").hidden = !(projectView || needsView)
|
|
683
|
+
$("#runs").hidden = projectView || needsView
|
|
684
|
+
if (boardView) renderBoard(state.tasks ?? [], "#kanban")
|
|
685
|
+
if (needsView) renderNeeds()
|
|
686
|
+
if (projectView) renderProject()
|
|
687
|
+
|
|
688
|
+
const visible = visibleSessions()
|
|
689
|
+
$("#scope").textContent =
|
|
690
|
+
view.kind === "project" ? `project ${view.value}`
|
|
691
|
+
: view.kind === "room" ? (state.rooms.find((r) => r.id === view.value)?.name ?? "")
|
|
692
|
+
: ""
|
|
693
|
+
$("#empty").hidden = state.sessions.length > 0
|
|
694
|
+
const grid = $("#grid")
|
|
695
|
+
const seen = new Set()
|
|
696
|
+
visible.forEach((s, i) => {
|
|
697
|
+
seen.add(s.id)
|
|
698
|
+
let r = pods.get(s.id)
|
|
699
|
+
if (!r) { r = podEl(s); pods.set(s.id, r) }
|
|
700
|
+
updatePod(r, s)
|
|
701
|
+
const at = grid.children[i]
|
|
702
|
+
if (at !== r.el && !r.el.contains(document.activeElement)) grid.insertBefore(r.el, at ?? null)
|
|
703
|
+
})
|
|
704
|
+
for (const [id, r] of pods) if (!seen.has(id)) { r.el.remove(); pods.delete(id) }
|
|
705
|
+
|
|
706
|
+
renderNav()
|
|
707
|
+
$("#feed").innerHTML = (state.events ?? [])
|
|
708
|
+
.map((e) => `<div class="${e.level}">${new Date(e.at).toLocaleTimeString()} ${esc(e.text)}</div>`)
|
|
709
|
+
.join("")
|
|
710
|
+
if (fromStream && openId) refreshModal()
|
|
711
|
+
if (fromStream && roomId) renderRoom()
|
|
712
|
+
if (fromStream && taskId) renderTask()
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// ------------------------------------------------------------------ task board
|
|
716
|
+
const COLUMNS = ["backlog", "blocked", "queued", "active", "review", "done", "cancelled"]
|
|
717
|
+
const boardSig = new Map()
|
|
718
|
+
function renderBoard(tasks, target) {
|
|
719
|
+
const cols = COLUMNS.map((status) => ({ status, tasks: tasks.filter((t) => t.status === status) }))
|
|
720
|
+
.filter((c) => c.tasks.length || ["backlog", "queued", "active", "review", "done"].includes(c.status))
|
|
721
|
+
const sig = JSON.stringify(cols.map((c) => [c.status, c.tasks.map((t) => [t.id, t.status, t.title, t.assignee])]))
|
|
722
|
+
if (sig === boardSig.get(target)) return
|
|
723
|
+
boardSig.set(target, sig)
|
|
724
|
+
const byId = Object.fromEntries(state.sessions.map((s) => [s.id, s]))
|
|
725
|
+
$(target).innerHTML = cols.map((c) => `<div class="col ${c.status}">
|
|
726
|
+
<h5>${c.status}<span>${c.tasks.length}</span></h5>
|
|
727
|
+
${c.tasks.map((t) => {
|
|
728
|
+
const who = byId[t.assignee]
|
|
729
|
+
return `<div class="card ${t.kind === "question" ? "question" : ""}" data-t="${t.id}">
|
|
730
|
+
<div class="t">${esc(t.title)}</div>
|
|
731
|
+
<div class="s">
|
|
732
|
+
${
|
|
733
|
+
t.owner === "human"
|
|
734
|
+
? `<span style="color:var(--wait)">you</span>`
|
|
735
|
+
: who
|
|
736
|
+
? `<img src="${avatarUrl(who)}" alt="" />@${esc(who.handle ?? "")}`
|
|
737
|
+
: t.role
|
|
738
|
+
? `<span>any ${esc(t.role)}</span>`
|
|
739
|
+
: "unassigned"
|
|
740
|
+
}
|
|
741
|
+
${t.stage ? `<span class="stage">${esc(t.stage)}</span>` : ""}
|
|
742
|
+
${t.dependsOn?.length ? `<span>waits on ${t.dependsOn.length}</span>` : ""}
|
|
743
|
+
</div></div>`
|
|
744
|
+
}).join("")}
|
|
745
|
+
</div>`).join("")
|
|
746
|
+
for (const el of $$(`${target} .card`)) el.onclick = () => openTask(el.dataset.t)
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Everything waiting on you, questions first — they are blocking a bot that
|
|
751
|
+
* has stopped to ask rather than guessing.
|
|
752
|
+
*/
|
|
753
|
+
let needsSig = null
|
|
754
|
+
function renderNeeds() {
|
|
755
|
+
const mine = (state.tasks ?? [])
|
|
756
|
+
.filter((t) => t.owner === "human" && !["done", "cancelled"].includes(t.status))
|
|
757
|
+
.sort((a, b) => (b.kind === "question") - (a.kind === "question") || a.created - b.created)
|
|
758
|
+
const sig = JSON.stringify(mine.map((t) => [t.id, t.status, t.answer]))
|
|
759
|
+
const el = $("#project-view")
|
|
760
|
+
if (sig === needsSig) return
|
|
761
|
+
needsSig = sig
|
|
762
|
+
const byId = Object.fromEntries(state.sessions.map((x) => [x.id, x]))
|
|
763
|
+
el.innerHTML = `
|
|
764
|
+
<div class="phead"><h2>Needs you</h2><span class="sub">${mine.length} open</span></div>
|
|
765
|
+
${
|
|
766
|
+
mine.length
|
|
767
|
+
? mine.map((t) => {
|
|
768
|
+
const who = byId[t.askedBy]
|
|
769
|
+
return `<div class="ask" data-t="${t.id}">
|
|
770
|
+
<div class="asker">${who ? `<img src="${avatarUrl(who)}" alt="" />@${esc(who.handle ?? "")}` : "pipeline"}
|
|
771
|
+
${t.kind === "question" ? "asks" : "needs you"} · ${esc(t.projectId ?? "")}</div>
|
|
772
|
+
<div class="q">${esc(t.brief || t.title)}</div>
|
|
773
|
+
${t.options?.length ? `<div class="opts">${t.options.map((o) => `<button class="tiny opt" data-t="${t.id}" data-a="${esc(o)}">${esc(o)}</button>`).join("")}</div>` : ""}
|
|
774
|
+
<div class="reply"><input placeholder="Answer… (⏎)" data-t="${t.id}" />
|
|
775
|
+
<button class="primary tiny send" data-t="${t.id}">Answer</button>
|
|
776
|
+
<button class="ghost tiny open" data-t="${t.id}">Open task</button></div>
|
|
777
|
+
</div>`
|
|
778
|
+
}).join("")
|
|
779
|
+
: `<div class="empty"><b>Nothing waiting on you</b>When a bot needs a decision it will appear here, and the answer goes straight back to it.</div>`
|
|
780
|
+
}`
|
|
781
|
+
const answer = async (id, text) => {
|
|
782
|
+
if (!text?.trim()) return
|
|
783
|
+
await api(`/api/tasks/${id}/answer`, { method: "POST", body: { answer: text.trim() } }).catch((e) => alert(e.message))
|
|
784
|
+
needsSig = null
|
|
785
|
+
render()
|
|
786
|
+
}
|
|
787
|
+
for (const b of $$(".ask .opt", el)) b.onclick = () => answer(b.dataset.t, b.dataset.a)
|
|
788
|
+
for (const b of $$(".ask .send", el)) {
|
|
789
|
+
b.onclick = () => answer(b.dataset.t, $(`.ask .reply input[data-t="${b.dataset.t}"]`, el).value)
|
|
790
|
+
}
|
|
791
|
+
for (const i of $$(".ask .reply input", el)) {
|
|
792
|
+
i.onkeydown = (e) => { if (e.key === "Enter") answer(i.dataset.t, i.value) }
|
|
793
|
+
}
|
|
794
|
+
for (const b of $$(".ask .open", el)) b.onclick = () => openTask(b.dataset.t)
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* A project in one screen: who is working, what is on the board, and the
|
|
799
|
+
* conversation about it — rather than three places to visit in turn.
|
|
800
|
+
*/
|
|
801
|
+
let projectSig = null
|
|
802
|
+
function renderProject() {
|
|
803
|
+
const p = (state.projects ?? []).find((x) => x.id === view.value)
|
|
804
|
+
if (!p) { view = { kind: "all", value: null }; return }
|
|
805
|
+
const tasks = (state.tasks ?? []).filter((t) => t.projectId === p.id)
|
|
806
|
+
const room = (state.rooms ?? []).find((r) => r.id === p.roomId)
|
|
807
|
+
const sig = JSON.stringify([p, tasks.map((t) => [t.id, t.status]), room?.messages?.length, room?.muted])
|
|
808
|
+
const el = $("#project-view")
|
|
809
|
+
const prior = $("#pc-input")
|
|
810
|
+
const keep = prior ? { text: prior.value, focused: document.activeElement === prior } : {}
|
|
811
|
+
if (sig !== projectSig) {
|
|
812
|
+
projectSig = sig
|
|
813
|
+
// The board element is about to be replaced, so its cached signature no
|
|
814
|
+
// longer describes anything on screen.
|
|
815
|
+
boardSig.delete("#p-kanban")
|
|
816
|
+
el.innerHTML = `
|
|
817
|
+
<div class="phead">
|
|
818
|
+
<h2>${esc(p.name)}</h2>
|
|
819
|
+
<span class="sub">${esc(p.branch ?? "")}${p.worktree ? " · " + esc(fmt.path(p.worktree, 2)) : ""}</span>
|
|
820
|
+
<span class="right">
|
|
821
|
+
<button class="ghost tiny" id="p-newtask">New task</button>
|
|
822
|
+
<button class="ghost tiny" id="p-addsession">Add session</button>
|
|
823
|
+
<button class="ghost tiny" id="p-runpipe">Run a pipeline</button>
|
|
824
|
+
</span>
|
|
825
|
+
</div>
|
|
826
|
+
<div class="crew">${
|
|
827
|
+
p.sessions.length
|
|
828
|
+
? p.sessions.map((s) => `<span class="who" data-s="${s.id}" data-status="${esc(s.status)}">
|
|
829
|
+
<img src="/avatar/${encodeURIComponent(s.id)}.svg?h=${encodeURIComponent(s.handle ?? "")}" alt="" />@${esc(s.handle ?? "")}
|
|
830
|
+
<span style="color:var(--faint)">${esc(s.status)}</span><span class="doing"></span></span>`).join("")
|
|
831
|
+
: `<span class="note">No sessions in this project yet.</span>`
|
|
832
|
+
}</div>
|
|
833
|
+
<div class="project">
|
|
834
|
+
<div class="work"><div class="kanban" id="p-kanban"></div></div>
|
|
835
|
+
<div class="chatpane">
|
|
836
|
+
<div class="ph"><b>${esc(room?.name ?? "#" + p.id)}</b>
|
|
837
|
+
<span class="note" style="margin:0">${room ? `${room.members.length} members` : "chat starts with the second session"}</span>
|
|
838
|
+
${room ? `<button class="ghost tiny" id="pc-manage" style="margin-left:auto">Manage</button>` : ""}</div>
|
|
839
|
+
<div class="log" id="pc-log">${
|
|
840
|
+
room?.messages?.length
|
|
841
|
+
? room.messages.map((m) => {
|
|
842
|
+
const member = room.members.find((x) => x.id === m.fromId)
|
|
843
|
+
return `<div class="turn ${m.from === "operator" ? "op" : ""}">
|
|
844
|
+
${m.from === "operator" ? `<span class="avdot"></span>` : `<img src="/avatar/${encodeURIComponent(m.fromId ?? m.from)}.svg?h=${encodeURIComponent(member?.handle ?? m.from)}" alt="" />`}
|
|
845
|
+
<div class="c"><div class="m">${m.from === "operator" ? "operator" : "@" + esc(m.from)} · ${new Date(m.at).toLocaleTimeString()}</div>
|
|
846
|
+
<div class="b">${esc(m.text)}</div></div></div>`
|
|
847
|
+
}).join("")
|
|
848
|
+
: `<div class="note">Nothing said yet. Anything you post here reaches every session in the project.</div>`
|
|
849
|
+
}</div>
|
|
850
|
+
<div class="composer"><textarea id="pc-input" placeholder="Post to the project… (@handle reaches one)"></textarea>
|
|
851
|
+
<button class="primary" id="pc-send">Post</button></div>
|
|
852
|
+
</div>
|
|
853
|
+
</div>`
|
|
854
|
+
const log = $("#pc-log", el)
|
|
855
|
+
log.scrollTop = log.scrollHeight
|
|
856
|
+
for (const w of $$(".crew .who", el)) w.onclick = () => openSession(w.dataset.s)
|
|
857
|
+
if ($("#pc-manage", el)) $("#pc-manage", el).onclick = () => openRoom(room.id)
|
|
858
|
+
$("#p-newtask", el).onclick = () => openTaskDialog(p.id)
|
|
859
|
+
$("#p-runpipe", el).onclick = () => openPipelineDialog(p)
|
|
860
|
+
$("#p-addsession", el).onclick = () => { groupOptions($("#f-group"), p.id); $("#f-repo").value = p.repo ?? ""; $("#new-dialog").showModal() }
|
|
861
|
+
const send = async () => {
|
|
862
|
+
const message = $("#pc-input", el).value.trim()
|
|
863
|
+
if (!message) return
|
|
864
|
+
$("#pc-input", el).value = ""
|
|
865
|
+
await api(`/api/projects/${p.id}/chat`, { method: "POST", body: { message } }).catch((e) => alert(e.message))
|
|
866
|
+
}
|
|
867
|
+
$("#pc-send", el).onclick = send
|
|
868
|
+
$("#pc-input", el).onkeydown = (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send() } }
|
|
869
|
+
const input = $("#pc-input", el)
|
|
870
|
+
if (keep.text) input.value = keep.text
|
|
871
|
+
if (keep.focused) input.focus()
|
|
872
|
+
}
|
|
873
|
+
renderBoard(tasks, "#p-kanban")
|
|
874
|
+
// What each bot is doing right now, refreshed on every push.
|
|
875
|
+
const byId = Object.fromEntries(state.sessions.map((s) => [s.id, s]))
|
|
876
|
+
for (const w of $$(".crew .who", el)) {
|
|
877
|
+
const s = byId[w.dataset.s]
|
|
878
|
+
const line = $(".doing", w)
|
|
879
|
+
if (!s || !line) continue
|
|
880
|
+
const doing = s.status === "busy" && s.currentTool ? `▸ ${s.currentTool.name}` : s.lastText || ""
|
|
881
|
+
line.textContent = doing
|
|
882
|
+
line.title = doing
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
let runsSig = null
|
|
887
|
+
function renderRuns() {
|
|
888
|
+
const runs = state.runs ?? []
|
|
889
|
+
const sig = JSON.stringify(runs.map((r) => [r.id, r.status, r.tasks.map((t) => t.status)]))
|
|
890
|
+
if (sig === runsSig) return
|
|
891
|
+
runsSig = sig
|
|
892
|
+
$("#runs").innerHTML = runs.map((r) => `<div class="run">
|
|
893
|
+
<div class="rt">${esc(r.title)}</div>
|
|
894
|
+
<div class="flow">${r.tasks.map((t) => `<span class="step ${esc(t.status)}" title="${esc(t.title ?? "")}">${esc(t.stage)}</span>`).join(`<span class="arrow">→</span>`)}</div>
|
|
895
|
+
<div class="s" style="margin-top:6px;font-size:10.5px;color:var(--faint);font-family:var(--mono)">${esc(r.pipeline)} · ${esc(r.branch ?? r.repo ?? "")}
|
|
896
|
+
<a href="/api/runs/${r.id}/export" style="color:var(--dim);margin-left:8px">export yaml</a></div>
|
|
897
|
+
</div>`).join("")
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
async function openTask(id) {
|
|
901
|
+
openId = null; roomId = null; taskId = id; taskSig = null
|
|
902
|
+
await renderTask(true)
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
let taskId = null
|
|
906
|
+
let taskSig = null
|
|
907
|
+
async function renderTask(create = false) {
|
|
908
|
+
let t, files
|
|
909
|
+
try {
|
|
910
|
+
t = await api(`/api/tasks/${taskId}`)
|
|
911
|
+
files = (await api(`/api/tasks/${taskId}/diff`)).files
|
|
912
|
+
} catch { return closeModal() }
|
|
913
|
+
const sig = JSON.stringify([t.status, t.handoff, t.notes.length, files])
|
|
914
|
+
if (!create && sig === taskSig) return
|
|
915
|
+
taskSig = sig
|
|
916
|
+
const el = shell(create)
|
|
917
|
+
const who = state.sessions.find((s) => s.id === t.assignee)
|
|
918
|
+
// The task and the conversation it is being done in, side by side: knowing
|
|
919
|
+
// what was asked is useless one click away from seeing what is happening.
|
|
920
|
+
const chat = who ? await api(`/api/sessions/${who.id}`).catch(() => null) : null
|
|
921
|
+
el.classList.add("split-modal")
|
|
922
|
+
el.innerHTML = `
|
|
923
|
+
<div class="left split">
|
|
924
|
+
<div class="taskside">
|
|
925
|
+
<div class="top"><span class="h">${esc(t.title)}</span><span class="s">${esc(t.stage ?? "task")} · ${esc(t.status)}</span>
|
|
926
|
+
<span class="right"><button class="ghost tiny" id="tk-close">Close</button></span></div>
|
|
927
|
+
<div class="sec" style="margin:0"><h3>Brief</h3><div class="turn"><div class="c"><div class="b">${esc(t.brief) || "—"}</div></div></div></div>
|
|
928
|
+
${t.acceptance?.length ? `<div class="sec"><h3>Acceptance criteria</h3>${t.acceptance.map((a) => `<div class="row"><span class="g">${esc(a)}</span></div>`).join("")}</div>` : ""}
|
|
929
|
+
${t.handoff ? `<div class="sec"><h3>Handoff from ${esc(t.handoff.by ?? "")}</h3>
|
|
930
|
+
<div class="turn"><div class="c"><div class="b">${esc(t.handoff.summary)}</div></div></div>
|
|
931
|
+
${t.handoff.acceptance_criteria?.length ? `<div class="note">Criteria: ${t.handoff.acceptance_criteria.map(esc).join(" · ")}</div>` : ""}
|
|
932
|
+
${t.handoff.open_questions?.length ? `<div class="note" style="color:var(--busy)">Open: ${t.handoff.open_questions.map(esc).join(" · ")}</div>` : ""}
|
|
933
|
+
</div>` : ""}
|
|
934
|
+
<div class="sec"><h3>Changed files</h3>${
|
|
935
|
+
files.length
|
|
936
|
+
? files.map((f) => `<div class="row"><span class="g file" data-f="${esc(f.path)}" style="cursor:pointer;text-decoration:underline">${esc(f.path)}</span>
|
|
937
|
+
<span class="st">${f.untracked ? "new" : `+${f.added} −${f.removed}`}</span></div>`).join("")
|
|
938
|
+
: `<div class="note">Nothing changed in this worktree yet.</div>`
|
|
939
|
+
}<div id="tk-diff"></div></div>
|
|
940
|
+
${t.notes.length ? `<div class="sec"><h3>Notes</h3>${t.notes.map((n) => `<div class="row"><span class="st">@${esc(n.by)}</span><span class="g">${esc(n.text)}</span></div>`).join("")}</div>` : ""}
|
|
941
|
+
</div>
|
|
942
|
+
<div style="display:flex;flex-direction:column;min-width:0">
|
|
943
|
+
<div class="top">
|
|
944
|
+
${who ? `<img class="avatar" style="width:34px;height:34px" src="${avatarUrl(who)}" alt="" /><span class="h">@${esc(who.handle ?? "")}</span>
|
|
945
|
+
<span class="s">${esc(who.status)}</span>` : `<span class="s">No session assigned — this task is in the backlog.</span>`}
|
|
946
|
+
${who ? `<span class="right"><button class="ghost tiny" id="tk-full">Open session</button></span>` : ""}
|
|
947
|
+
</div>
|
|
948
|
+
<div class="transcript" id="tk-chat">${
|
|
949
|
+
chat?.transcript?.length
|
|
950
|
+
? chat.transcript.slice().reverse().map((m) => `<div class="turn ${esc(m.role)}">
|
|
951
|
+
${m.role === "assistant" ? `<img src="${avatarUrl(who)}" alt="" />` : `<span class="avdot"></span>`}
|
|
952
|
+
<div class="c"><div class="m">${m.role === "assistant" ? "@" + esc(who.handle) : esc(m.role)}</div>
|
|
953
|
+
<div class="b">${esc(m.text)}</div>
|
|
954
|
+
${m.tools?.length ? `<div class="tools">${m.tools.map((x) => `<span>${esc(x.name)}</span>`).join("")}</div>` : ""}</div></div>`).join("")
|
|
955
|
+
: `<div class="empty">${who ? "Nothing in this session yet." : "Assign this task to see the conversation."}</div>`
|
|
956
|
+
}</div>
|
|
957
|
+
${who ? `<div class="composer"><textarea id="tk-say" placeholder="Say something to @${esc(who.handle ?? "")}… (⏎)"></textarea>
|
|
958
|
+
<button class="primary" id="tk-send">Send</button></div>` : ""}
|
|
959
|
+
</div>
|
|
960
|
+
</div>
|
|
961
|
+
<div class="side">
|
|
962
|
+
<div class="sec"><h3>Task</h3><table class="kv">
|
|
963
|
+
<tr><td>status</td><td>${esc(t.status)}</td></tr>
|
|
964
|
+
<tr><td>assignee</td><td>${who ? "@" + esc(who.handle) : esc(t.persona ?? "—")}</td></tr>
|
|
965
|
+
<tr><td>raised by</td><td>${esc(t.createdBy)}</td></tr>
|
|
966
|
+
<tr><td>blocked by</td><td>${t.dependsOn.length || "—"}</td></tr>
|
|
967
|
+
</table></div>
|
|
968
|
+
<div class="sec"><h3>Move to</h3>
|
|
969
|
+
<div style="display:flex;flex-wrap:wrap;gap:6px">
|
|
970
|
+
${["backlog", "queued", "active", "review", "done", "cancelled"].map((c) => `<button class="tiny tk-move ${c === t.status ? "primary" : "ghost"}" data-c="${c}">${c}</button>`).join("")}
|
|
971
|
+
</div>
|
|
972
|
+
<div class="note">Moving to queued offers it to the assignee when they next go idle.</div>
|
|
973
|
+
</div>
|
|
974
|
+
${who ? `<div class="sec"><h3>Assignee</h3><div class="row"><img src="${avatarUrl(who)}" style="width:28px;height:28px;border-radius:6px" alt="" />
|
|
975
|
+
<span class="g">@${esc(who.handle)}</span><button class="ghost tiny" id="tk-open">Open session</button></div></div>` : ""}
|
|
976
|
+
</div>`
|
|
977
|
+
const chatLog = $("#tk-chat", el)
|
|
978
|
+
if (chatLog) chatLog.scrollTop = chatLog.scrollHeight
|
|
979
|
+
$("#tk-close", el).onclick = closeModal
|
|
980
|
+
if ($("#tk-open", el)) $("#tk-open", el).onclick = () => openSession(who.id)
|
|
981
|
+
if ($("#tk-full", el)) $("#tk-full", el).onclick = () => openSession(who.id)
|
|
982
|
+
const say = $("#tk-say", el)
|
|
983
|
+
if (say) {
|
|
984
|
+
const send = async () => {
|
|
985
|
+
const text = say.value.trim()
|
|
986
|
+
if (!text) return
|
|
987
|
+
say.value = ""
|
|
988
|
+
await api(`/api/sessions/${who.id}/send`, { method: "POST", body: { text } }).catch((e) => alert(e.message))
|
|
989
|
+
taskSig = null
|
|
990
|
+
renderTask()
|
|
991
|
+
}
|
|
992
|
+
$("#tk-send", el).onclick = send
|
|
993
|
+
say.onkeydown = (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send() } }
|
|
994
|
+
}
|
|
995
|
+
for (const b of $$(".tk-move", el)) {
|
|
996
|
+
b.onclick = async () => { await api(`/api/tasks/${t.id}`, { method: "POST", body: { status: b.dataset.c } }); taskSig = null; renderTask() }
|
|
997
|
+
}
|
|
998
|
+
for (const f of $$(".file", el)) {
|
|
999
|
+
f.onclick = async () => {
|
|
1000
|
+
const { diff } = await api(`/api/tasks/${t.id}/diff?file=${encodeURIComponent(f.dataset.f)}`)
|
|
1001
|
+
$("#tk-diff", el).innerHTML = `<div class="diff">${(diff || "(no diff)").split("\n").slice(0, 600).map((l) =>
|
|
1002
|
+
`<div class="${l.startsWith("+") && !l.startsWith("+++") ? "add" : l.startsWith("-") && !l.startsWith("---") ? "del" : l.startsWith("@@") ? "hunk" : ""}">${esc(l) || " "}</div>`).join("")}</div>`
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// ------------------------------------------------------------------ modal shell
|
|
1008
|
+
function shell(create) {
|
|
1009
|
+
let el = $("#modal")
|
|
1010
|
+
if (!el || create) {
|
|
1011
|
+
$("#scrim")?.remove()
|
|
1012
|
+
$("#modal")?.remove()
|
|
1013
|
+
const scrim = document.createElement("div")
|
|
1014
|
+
scrim.className = "scrim"
|
|
1015
|
+
scrim.id = "scrim"
|
|
1016
|
+
scrim.onclick = closeModal
|
|
1017
|
+
document.body.append(scrim)
|
|
1018
|
+
el = document.createElement("div")
|
|
1019
|
+
el.className = "modal"
|
|
1020
|
+
el.id = "modal"
|
|
1021
|
+
document.body.append(el)
|
|
1022
|
+
document.body.classList.add("modal-open")
|
|
1023
|
+
}
|
|
1024
|
+
return el
|
|
1025
|
+
}
|
|
1026
|
+
function closeModal() {
|
|
1027
|
+
if (openId) wsSend(`unwatch:${openId}`)
|
|
1028
|
+
live = { id: null, msgs: null, status: null, currentTool: null, at: 0 }
|
|
1029
|
+
openId = null; roomId = null; taskId = null; modalSig = null; roomSig = null; taskSig = null
|
|
1030
|
+
$("#scrim")?.remove()
|
|
1031
|
+
$("#modal")?.remove()
|
|
1032
|
+
document.body.classList.remove("modal-open")
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
async function openSession(id) {
|
|
1036
|
+
if (openId && openId !== id) wsSend(`unwatch:${openId}`)
|
|
1037
|
+
live = { id, msgs: null, status: null, currentTool: null, at: 0 }
|
|
1038
|
+
wsSend(`watch:${id}`)
|
|
1039
|
+
openId = id; roomId = null; modalSig = null
|
|
1040
|
+
await refreshModal(true)
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
async function refreshModal(create = false) {
|
|
1044
|
+
$("#modal")?.classList.remove("split-modal")
|
|
1045
|
+
let d
|
|
1046
|
+
try { d = await api(`/api/sessions/${openId}`) } catch { return closeModal() }
|
|
1047
|
+
const prior = $("#m-input")
|
|
1048
|
+
const keep = prior ? { text: prior.value, focused: document.activeElement === prior } : {}
|
|
1049
|
+
const sig = JSON.stringify([d.transcript?.length, d.status, d.currentTool, d.totals, d.permissions?.length, d.policy, d.tools, d.git?.dirty, d.rooms, d.group])
|
|
1050
|
+
if (!create && sig === modalSig) return
|
|
1051
|
+
modalSig = sig
|
|
1052
|
+
const el = shell(create)
|
|
1053
|
+
const old = $(".transcript", el)
|
|
1054
|
+
const atBottom = !old || old.scrollHeight - old.scrollTop - old.clientHeight < 80
|
|
1055
|
+
// The socket keeps the transcript current between refreshes; only fall back
|
|
1056
|
+
// to the detail fetch when no live frame has arrived recently.
|
|
1057
|
+
if (live.id !== d.id || !live.msgs || Date.now() - live.at > 4000) {
|
|
1058
|
+
live = { id: d.id, msgs: (d.transcript ?? []).slice().reverse(), status: d.status, currentTool: d.currentTool, at: live.id === d.id ? live.at : 0 }
|
|
1059
|
+
} else {
|
|
1060
|
+
live.status = d.status
|
|
1061
|
+
live.currentTool = d.currentTool
|
|
1062
|
+
}
|
|
1063
|
+
live.handle = d.handle
|
|
1064
|
+
|
|
1065
|
+
el.innerHTML = `
|
|
1066
|
+
<div class="left">
|
|
1067
|
+
<div class="top">
|
|
1068
|
+
<img class="avatar" style="width:38px;height:38px" src="${avatarUrl(d)}" alt="" />
|
|
1069
|
+
<span class="h">@${esc(d.handle ?? "")}</span><span class="s">${esc(d.name)}</span>
|
|
1070
|
+
<span class="right">
|
|
1071
|
+
<button class="ghost tiny" id="m-compact">Compact</button>
|
|
1072
|
+
<button class="danger tiny" id="m-abort">Abort</button>
|
|
1073
|
+
<button class="ghost tiny" id="m-close">Close</button>
|
|
1074
|
+
</span>
|
|
1075
|
+
</div>
|
|
1076
|
+
<div class="transcript">${transcriptBody(d)}</div>
|
|
1077
|
+
${d.permissions?.length ? `<div class="perm" style="margin:0 16px 8px"><b>${esc(d.permissions[0].action ?? "permission")}</b>
|
|
1078
|
+
<button class="allow" style="margin-left:auto">Allow once</button><button class="always">Always</button><button class="deny danger">Deny</button></div>` : ""}
|
|
1079
|
+
<div class="composer">
|
|
1080
|
+
<textarea id="m-input" placeholder="Continue this session… (⏎ to send, ⇧⏎ newline)"></textarea>
|
|
1081
|
+
<button class="primary" id="m-send">Send</button>
|
|
1082
|
+
</div>
|
|
1083
|
+
</div>
|
|
1084
|
+
<div class="side">
|
|
1085
|
+
<div class="sec"><h3>Session</h3><table class="kv">
|
|
1086
|
+
<tr><td>status</td><td>${esc(d.status)}${d.currentTool ? " · " + esc(d.currentTool.name) : ""}</td></tr>
|
|
1087
|
+
<tr><td>worktree</td><td>${esc(fmt.path(d.directory))}</td></tr>
|
|
1088
|
+
<tr><td>branch</td><td>${esc(d.branch ?? "—")}</td></tr>
|
|
1089
|
+
<tr><td>model</td><td>${esc(d.model ?? "—")}</td></tr>
|
|
1090
|
+
<tr><td>changes</td><td>${d.git ? `+${d.git.insertions} −${d.git.deletions} · ${d.git.dirty}` : "—"}</td></tr>
|
|
1091
|
+
</table>
|
|
1092
|
+
<div class="row" style="border:0;padding-top:8px">
|
|
1093
|
+
<span class="st">project</span>
|
|
1094
|
+
<select id="m-group" style="flex:1">${(state.projects ?? []).map((p) => `<option value="${p.id}" ${p.id === d.project ? "selected" : ""}>${esc(p.name)}</option>`).join("")}</select>
|
|
1095
|
+
<button class="ghost tiny danger" id="m-release">Remove</button>
|
|
1096
|
+
</div></div>
|
|
1097
|
+
|
|
1098
|
+
<div class="sec"><h3>Cost</h3><table class="kv">
|
|
1099
|
+
<tr><td>input</td><td>${fmt.n(d.totals.input)}</td></tr>
|
|
1100
|
+
<tr><td>output</td><td>${fmt.n(d.totals.output)}</td></tr>
|
|
1101
|
+
<tr><td>cache r/w</td><td>${fmt.n(d.totals.cacheRead)} / ${fmt.n(d.totals.cacheWrite)}</td></tr>
|
|
1102
|
+
<tr><td>context</td><td>${fmt.n(d.contextTokens)}</td></tr>
|
|
1103
|
+
<tr><td>tools</td><td>${d.totals.tools} (${d.totals.toolErrors} err)</td></tr>
|
|
1104
|
+
<tr><td>${d.cost?.estimated ? "estimated" : "reported"}</td><td>${fmt.money(d.cost?.usd)}</td></tr>
|
|
1105
|
+
</table>${
|
|
1106
|
+
d.cost?.unknownModel
|
|
1107
|
+
? `<div class="note">No price set for <span class="num">${esc(d.cost.unknownModel)}</span>. Add one under "pricing" in ~/.botfarm/config.json.</div>`
|
|
1108
|
+
: d.cost?.estimated
|
|
1109
|
+
? `<div class="note">Estimated from token counts — the provider reported no cost.</div>`
|
|
1110
|
+
: ""
|
|
1111
|
+
}</div>
|
|
1112
|
+
|
|
1113
|
+
<div class="sec"><h3>Tools</h3>
|
|
1114
|
+
${(d.tools?.servers ?? []).map((m) => `<div class="row">
|
|
1115
|
+
<span class="g">${esc(m.name)}</span><span class="st">${esc(m.status)}</span>
|
|
1116
|
+
<button class="sw mcp-sw" data-n="${esc(m.name)}" role="switch" aria-checked="${m.connected}"></button></div>`).join("")
|
|
1117
|
+
|| `<div class="note">No MCP servers for this worktree.</div>`}
|
|
1118
|
+
${(d.tools?.builtin ?? []).map((t) => `<div class="row">
|
|
1119
|
+
<span class="g">${esc(t.name)}</span>
|
|
1120
|
+
<button class="sw tool-sw" data-n="${esc(t.name)}" role="switch" aria-checked="${t.enabled}"></button></div>`).join("")}
|
|
1121
|
+
<div class="note">MCP servers toggle live. Built-in tools are written to this worktree's opencode config and apply when the session restarts.</div>
|
|
1122
|
+
</div>
|
|
1123
|
+
|
|
1124
|
+
<div class="sec"><h3>Mesh</h3>
|
|
1125
|
+
<div class="row" style="border:0"><span class="st">peer messages</span>
|
|
1126
|
+
<span class="seg" id="m-talk" style="margin-left:auto">
|
|
1127
|
+
<button data-v="off" aria-pressed="${d.policy.talk === "off"}">Off</button>
|
|
1128
|
+
<button data-v="inbox" aria-pressed="${d.policy.talk === "inbox"}">Receive</button>
|
|
1129
|
+
<button data-v="open" aria-pressed="${d.policy.talk === "open"}">Both</button>
|
|
1130
|
+
</span></div>
|
|
1131
|
+
<div class="row" style="border:0"><span class="st">may open sessions</span>
|
|
1132
|
+
<button class="sw" id="m-spawn" role="switch" aria-checked="${d.policy.spawn}" style="margin-left:auto"></button></div>
|
|
1133
|
+
<div class="row" style="border:0"><span class="st">group chats</span>
|
|
1134
|
+
<span class="seg" id="m-rooms" style="margin-left:auto">
|
|
1135
|
+
<button data-v="off" aria-pressed="${d.policy.rooms === "off"}">Off</button>
|
|
1136
|
+
<button data-v="member" aria-pressed="${d.policy.rooms === "member"}">Member</button>
|
|
1137
|
+
<button data-v="create" aria-pressed="${d.policy.rooms === "create"}">Create</button>
|
|
1138
|
+
</span></div>
|
|
1139
|
+
${d.rooms?.length ? `<div class="note">In ${d.rooms.map((r) => esc(r.name)).join(", ")}</div>` : ""}
|
|
1140
|
+
${d.origin ? `<div class="note">Opened by @${esc(d.origin)}</div>` : ""}
|
|
1141
|
+
${d.mcpUrl && d.policy.talk !== "off" ? `<div class="wire">${esc(d.mcpUrl)}</div>` : ""}
|
|
1142
|
+
</div>
|
|
1143
|
+
|
|
1144
|
+
<div class="sec"><h3>Recent tool calls</h3>${
|
|
1145
|
+
(d.toolLog ?? []).slice(0, 12).map((t) => `<div class="row">
|
|
1146
|
+
<span class="g" style="color:${t.status === "error" ? "var(--error)" : "var(--text)"}">${esc(t.name)}</span>
|
|
1147
|
+
<span class="st">${esc(t.summary ?? "")}</span><span class="st">${fmt.ms(t.ms)}</span></div>`).join("")
|
|
1148
|
+
|| `<div class="note">None yet.</div>`
|
|
1149
|
+
}</div>
|
|
1150
|
+
</div>`
|
|
1151
|
+
|
|
1152
|
+
const tr = $(".transcript", el)
|
|
1153
|
+
if (atBottom) tr.scrollTop = tr.scrollHeight
|
|
1154
|
+
tr.addEventListener("toggle", (e) => {
|
|
1155
|
+
const id = e.target.dataset?.p
|
|
1156
|
+
if (id) e.target.open ? openThoughts.add(id) : openThoughts.delete(id)
|
|
1157
|
+
}, true)
|
|
1158
|
+
const input = $("#m-input", el)
|
|
1159
|
+
if (keep.text) input.value = keep.text
|
|
1160
|
+
if (keep.focused) input.focus()
|
|
1161
|
+
const send = async () => {
|
|
1162
|
+
const text = input.value.trim()
|
|
1163
|
+
if (!text) return
|
|
1164
|
+
input.value = ""
|
|
1165
|
+
await api(`/api/sessions/${d.id}/send`, { method: "POST", body: { text } }).catch((e) => alert(e.message))
|
|
1166
|
+
modalSig = null
|
|
1167
|
+
refreshModal()
|
|
1168
|
+
}
|
|
1169
|
+
$("#m-send", el).onclick = send
|
|
1170
|
+
input.onkeydown = (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send() } }
|
|
1171
|
+
$("#m-close", el).onclick = closeModal
|
|
1172
|
+
$("#m-abort", el).onclick = () => api(`/api/sessions/${d.id}/abort`, { method: "POST" })
|
|
1173
|
+
$("#m-compact", el).onclick = () => api(`/api/sessions/${d.id}/compact`, { method: "POST" })
|
|
1174
|
+
$("#m-group", el).onchange = (e) => api(`/api/sessions/${d.id}/group`, { method: "POST", body: { project: e.target.value } })
|
|
1175
|
+
$("#m-release", el).onclick = async () => {
|
|
1176
|
+
if (!confirm("Remove from the board? The session itself is not deleted.")) return
|
|
1177
|
+
await api(`/api/sessions/${d.id}/release`, { method: "POST" })
|
|
1178
|
+
closeModal()
|
|
1179
|
+
}
|
|
1180
|
+
for (const b of $$("#m-talk button", el)) b.onclick = () => setPolicy(d.id, { talk: b.dataset.v })
|
|
1181
|
+
for (const b of $$("#m-rooms button", el)) b.onclick = () => setPolicy(d.id, { rooms: b.dataset.v })
|
|
1182
|
+
$("#m-spawn", el).onclick = () => setPolicy(d.id, { spawn: !d.policy.spawn })
|
|
1183
|
+
for (const b of $$(".mcp-sw", el)) {
|
|
1184
|
+
b.onclick = () => api(`/api/sessions/${d.id}/mcp`, { method: "POST", body: { name: b.dataset.n, connected: b.getAttribute("aria-checked") !== "true" } }).then(bump).catch((e) => alert(e.message))
|
|
1185
|
+
}
|
|
1186
|
+
for (const b of $$(".tool-sw", el)) {
|
|
1187
|
+
b.onclick = () => api(`/api/sessions/${d.id}/tools`, { method: "POST", body: { name: b.dataset.n, enabled: b.getAttribute("aria-checked") !== "true" } }).then(bump)
|
|
1188
|
+
}
|
|
1189
|
+
for (const [cls, dec] of [[".allow", "once"], [".always", "always"], [".deny", "reject"]]) {
|
|
1190
|
+
const b = $(cls, el)
|
|
1191
|
+
if (b) b.onclick = () => replyPermission(d.id, dec)
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
// ------------------------------------------------------------------ live transcript
|
|
1195
|
+
function transcriptBody(d) {
|
|
1196
|
+
const msgs = live.msgs ?? []
|
|
1197
|
+
if (!msgs.length) return `<div class="empty">Nothing in this session yet.</div>`
|
|
1198
|
+
const who = { id: d.id, handle: d.handle }
|
|
1199
|
+
const body = msgs.map((m) => {
|
|
1200
|
+
const bot = m.role === "assistant"
|
|
1201
|
+
const parts = m.parts?.length ? m.parts : [
|
|
1202
|
+
...(m.text ? [{ id: m.id + ":t", kind: "text", text: m.text }] : []),
|
|
1203
|
+
...(m.tools ?? []).map((t, i) => ({ id: m.id + ":" + i, kind: "tool", name: t.name, status: t.status })),
|
|
1204
|
+
]
|
|
1205
|
+
return `<div class="turn ${esc(m.role)}">
|
|
1206
|
+
${bot ? `<img src="${avatarUrl(who)}" alt="" />` : `<span class="avdot"></span>`}
|
|
1207
|
+
<div class="c">
|
|
1208
|
+
<div class="m">${bot ? "@" + esc(d.handle) : esc(m.role === "user" ? "operator" : m.role)}${m.at ? " · " + new Date(m.at).toLocaleTimeString() : ""}</div>
|
|
1209
|
+
${parts.map((p) => partHtml(p, bot)).join("") || `<span style="color:var(--faint)">…</span>`}
|
|
1210
|
+
</div></div>`
|
|
1211
|
+
}).join("")
|
|
1212
|
+
const working = live.status === "busy"
|
|
1213
|
+
? `<div class="working"><i></i>@${esc(d.handle)} is ${live.currentTool ? `running ${esc(live.currentTool.name)}` : "working"}…</div>`
|
|
1214
|
+
: ""
|
|
1215
|
+
return body + working
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
function partHtml(p, bot) {
|
|
1219
|
+
if (p.kind === "text") {
|
|
1220
|
+
const text = p.text ?? ""
|
|
1221
|
+
// Briefs and task prompts are long; show the start and let it expand.
|
|
1222
|
+
if (!bot && text.length > 700) {
|
|
1223
|
+
return `<details class="more" data-p="${esc(p.id)}" ${openThoughts.has(p.id) ? "open" : ""}>
|
|
1224
|
+
<summary><div class="b long">${esc(text.slice(0, 700))}</div><span class="lbl">show all ${fmt.n(text.length)} characters</span><span class="lbl less">show less</span></summary>
|
|
1225
|
+
<div class="b">${esc(text)}</div></details>`
|
|
1226
|
+
}
|
|
1227
|
+
return `<div class="b">${esc(text)}</div>`
|
|
1228
|
+
}
|
|
1229
|
+
if (p.kind === "reasoning") {
|
|
1230
|
+
const words = (p.text ?? "").split(/\s+/).filter(Boolean).length
|
|
1231
|
+
return `<details class="think" data-p="${esc(p.id)}" ${openThoughts.has(p.id) ? "open" : ""}>
|
|
1232
|
+
<summary>thinking · ${words} words</summary><div>${esc(p.text ?? "")}</div></details>`
|
|
1233
|
+
}
|
|
1234
|
+
if (p.kind === "tool") {
|
|
1235
|
+
const st = p.status === "completed" ? (p.ms != null ? fmt.ms(p.ms) : "done") : p.status ?? ""
|
|
1236
|
+
const row = `<div class="call s-${esc(p.status ?? "")}"><span class="nm">${esc(p.name ?? "tool")}</span>
|
|
1237
|
+
<span class="sm">${esc(p.summary ?? "")}</span><span class="st">${esc(st)}</span></div>`
|
|
1238
|
+
if (!p.output) return row
|
|
1239
|
+
return `<details data-p="${esc(p.id)}" ${openThoughts.has(p.id) ? "open" : ""}><summary style="list-style:none;cursor:pointer">${row}</summary>
|
|
1240
|
+
<div class="out">${esc(p.output)}</div></details>`
|
|
1241
|
+
}
|
|
1242
|
+
return ""
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
function onLive(f) {
|
|
1246
|
+
if (!openId || f.sessionId !== openId || live.id !== openId) return
|
|
1247
|
+
live.at = Date.now()
|
|
1248
|
+
if (f.t === "transcript") {
|
|
1249
|
+
// See index.html's onLive: keep the longer text per part so a lagging REST
|
|
1250
|
+
// snapshot can't erase streamed-in text/reasoning while tool parts persist.
|
|
1251
|
+
const prevById = new Map((live.msgs ?? []).map((m) => [m.id, m]))
|
|
1252
|
+
live.msgs = (f.transcript ?? []).slice().reverse().map((m) => {
|
|
1253
|
+
const prev = prevById.get(m.id)
|
|
1254
|
+
if (!prev?.parts?.length || !m.parts?.length) return m
|
|
1255
|
+
const prevParts = new Map(prev.parts.map((p) => [p.id, p]))
|
|
1256
|
+
return { ...m, parts: m.parts.map((p) => {
|
|
1257
|
+
const old = prevParts.get(p.id)
|
|
1258
|
+
if (old && typeof old.text === "string" && typeof p.text === "string" && old.text.length > p.text.length) {
|
|
1259
|
+
return { ...p, text: old.text }
|
|
1260
|
+
}
|
|
1261
|
+
return p
|
|
1262
|
+
}) }
|
|
1263
|
+
})
|
|
1264
|
+
live.status = f.status
|
|
1265
|
+
live.currentTool = f.currentTool
|
|
1266
|
+
} else if (f.t === "part") {
|
|
1267
|
+
if (!live.msgs) return
|
|
1268
|
+
let m = live.msgs.find((x) => x.id === f.messageId)
|
|
1269
|
+
if (!m) {
|
|
1270
|
+
if (!f.messageId) return
|
|
1271
|
+
m = { id: f.messageId, role: "assistant", at: Date.now(), parts: [] }
|
|
1272
|
+
live.msgs.push(m)
|
|
1273
|
+
}
|
|
1274
|
+
m.parts ??= []
|
|
1275
|
+
let part = m.parts.find((x) => x.id === f.part.id)
|
|
1276
|
+
if (!part) { part = { ...f.part }; m.parts.push(part); if (f.delta && !f.part.text) part.text = f.delta }
|
|
1277
|
+
else if (f.delta && f.part.text == null) part.text = (part.text ?? "") + f.delta
|
|
1278
|
+
else Object.assign(part, Object.fromEntries(Object.entries(f.part).filter(([, v]) => v != null)))
|
|
1279
|
+
if (f.part.kind === "tool") live.currentTool = /running|pending/.test(f.part.status) ? { name: f.part.name } : null
|
|
1280
|
+
live.status = "busy"
|
|
1281
|
+
}
|
|
1282
|
+
if (liveFrame) return
|
|
1283
|
+
liveFrame = requestAnimationFrame(() => {
|
|
1284
|
+
liveFrame = 0
|
|
1285
|
+
const tr = $("#modal .transcript")
|
|
1286
|
+
if (!tr || !openId) return
|
|
1287
|
+
const atBottom = tr.scrollHeight - tr.scrollTop - tr.clientHeight < 80
|
|
1288
|
+
tr.innerHTML = transcriptBody({ id: openId, handle: live.handle })
|
|
1289
|
+
if (atBottom) tr.scrollTop = tr.scrollHeight
|
|
1290
|
+
})
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
const bump = () => { modalSig = null; refreshModal() }
|
|
1294
|
+
const setPolicy = (id, body) => api(`/api/sessions/${id}/policy`, { method: "POST", body }).then(bump)
|
|
1295
|
+
|
|
1296
|
+
// ------------------------------------------------------------------ room modal
|
|
1297
|
+
function openRoom(id) { roomId = id; openId = null; roomSig = null; renderRoom(true) }
|
|
1298
|
+
|
|
1299
|
+
function renderRoom(create = false) {
|
|
1300
|
+
const r = (state.rooms ?? []).find((x) => x.id === roomId)
|
|
1301
|
+
if (!r) return closeModal()
|
|
1302
|
+
const sig = JSON.stringify([r.messages.length, r.members.length, r.muted, r.pending, r.stats])
|
|
1303
|
+
if (!create && sig === roomSig) return
|
|
1304
|
+
roomSig = sig
|
|
1305
|
+
const el = shell(create)
|
|
1306
|
+
const prior = $("#r-input")
|
|
1307
|
+
const keep = prior ? { text: prior.value, focused: document.activeElement === prior } : {}
|
|
1308
|
+
const others = state.sessions.filter((s) => !r.members.some((m) => m.id === s.id))
|
|
1309
|
+
el.innerHTML = `
|
|
1310
|
+
<div class="left">
|
|
1311
|
+
<div class="top"><span class="h">${esc(r.name)}</span><span class="s">${r.members.length} members</span>
|
|
1312
|
+
<span class="right">
|
|
1313
|
+
<button class="ghost tiny" id="rm-mute">${r.muted ? "Unmute" : "Mute"}</button>
|
|
1314
|
+
<button class="ghost tiny danger" id="rm-del">Delete</button>
|
|
1315
|
+
<button class="ghost tiny" id="rm-close">Close</button>
|
|
1316
|
+
</span></div>
|
|
1317
|
+
<div class="transcript">${
|
|
1318
|
+
r.messages.length
|
|
1319
|
+
? r.messages.map((m) => {
|
|
1320
|
+
const member = r.members.find((x) => x.id === m.fromId)
|
|
1321
|
+
return `<div class="turn ${m.from === "operator" ? "op" : ""}">
|
|
1322
|
+
${m.from === "operator" ? `<span class="avdot"></span>` : `<img src="/avatar/${encodeURIComponent(m.fromId ?? m.from)}.svg?h=${encodeURIComponent(member?.handle ?? m.from)}" alt="" />`}
|
|
1323
|
+
<div class="c"><div class="m">${m.from === "operator" ? "operator" : "@" + esc(m.from)} · ${new Date(m.at).toLocaleTimeString()}</div>
|
|
1324
|
+
<div class="b">${esc(m.text)}</div></div></div>`
|
|
1325
|
+
}).join("")
|
|
1326
|
+
: `<div class="empty">Nothing said yet. Anything you post reaches every member.</div>`
|
|
1327
|
+
}</div>
|
|
1328
|
+
<div class="composer">
|
|
1329
|
+
<textarea id="r-input" placeholder="Post as operator… (@handle reaches one member immediately)"></textarea>
|
|
1330
|
+
<button class="primary" id="rm-send">Post</button>
|
|
1331
|
+
</div>
|
|
1332
|
+
</div>
|
|
1333
|
+
<div class="side">
|
|
1334
|
+
<div class="sec"><h3>Room</h3>
|
|
1335
|
+
${r.topic ? `<div class="note" style="margin:0 0 8px">${esc(r.topic)}</div>` : ""}
|
|
1336
|
+
<div class="note">${r.mode === "push"
|
|
1337
|
+
? "Every message is delivered to every member's next step."
|
|
1338
|
+
: "Mentions are delivered immediately; everyone else gets one digest when they go idle."}</div>
|
|
1339
|
+
${r.pending ? `<div class="note">${r.pending} message(s) waiting for members to finish their step.</div>` : ""}
|
|
1340
|
+
${r.muted ? `<div class="note" style="color:var(--busy)">Muted after talking in circles.</div>` : ""}
|
|
1341
|
+
</div>
|
|
1342
|
+
<div class="sec"><h3>Members</h3>
|
|
1343
|
+
${r.members.map((m) => `<div class="row">
|
|
1344
|
+
<img src="/avatar/${encodeURIComponent(m.id)}.svg?h=${encodeURIComponent(m.handle ?? "")}" style="width:28px;height:28px;border-radius:6px" alt="" />
|
|
1345
|
+
<span class="g">@${esc(m.handle)}</span><span class="st">${esc(m.status)}</span>
|
|
1346
|
+
<button class="ghost tiny rm-kick" data-s="${m.id}">Remove</button></div>`).join("")}
|
|
1347
|
+
${others.length ? `<select id="rm-add" style="width:100%;margin-top:8px"><option value="">Add a session…</option>
|
|
1348
|
+
${others.map((o) => `<option value="${o.id}">@${esc(o.handle ?? o.id)} — ${esc(o.name)}</option>`).join("")}</select>` : ""}
|
|
1349
|
+
</div>
|
|
1350
|
+
<div class="sec"><h3>Delivery cost</h3><table class="kv">
|
|
1351
|
+
<tr><td>messages</td><td>${r.stats.posts}</td></tr>
|
|
1352
|
+
<tr><td>deliveries</td><td>${r.stats.deliveries}</td></tr>
|
|
1353
|
+
<tr><td>characters</td><td>${fmt.n(r.stats.charsDelivered)} (~${fmt.n(Math.round(r.stats.charsDelivered / 4))} tok)</td></tr>
|
|
1354
|
+
</table></div>
|
|
1355
|
+
</div>`
|
|
1356
|
+
const tr = $(".transcript", el)
|
|
1357
|
+
tr.scrollTop = tr.scrollHeight
|
|
1358
|
+
const input = $("#r-input", el)
|
|
1359
|
+
if (keep.text) input.value = keep.text
|
|
1360
|
+
if (keep.focused) input.focus()
|
|
1361
|
+
const post = async () => {
|
|
1362
|
+
const message = input.value.trim()
|
|
1363
|
+
if (!message) return
|
|
1364
|
+
input.value = ""
|
|
1365
|
+
await api(`/api/rooms/${r.id}/post`, { method: "POST", body: { message } }).catch((e) => alert(e.message))
|
|
1366
|
+
}
|
|
1367
|
+
$("#rm-send", el).onclick = post
|
|
1368
|
+
input.onkeydown = (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); post() } }
|
|
1369
|
+
$("#rm-close", el).onclick = closeModal
|
|
1370
|
+
$("#rm-mute", el).onclick = () => api(`/api/rooms/${r.id}/mute`, { method: "POST", body: { muted: !r.muted } })
|
|
1371
|
+
$("#rm-del", el).onclick = async () => {
|
|
1372
|
+
if (!confirm(`Delete ${r.name}?`)) return
|
|
1373
|
+
await api(`/api/rooms/${r.id}`, { method: "DELETE" })
|
|
1374
|
+
closeModal()
|
|
1375
|
+
}
|
|
1376
|
+
for (const b of $$(".rm-kick", el)) b.onclick = () => api(`/api/rooms/${r.id}/members`, { method: "POST", body: { sessionId: b.dataset.s, remove: true } })
|
|
1377
|
+
const add = $("#rm-add", el)
|
|
1378
|
+
if (add) add.onchange = () => add.value && api(`/api/rooms/${r.id}/members`, { method: "POST", body: { sessionId: add.value } })
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// ------------------------------------------------------------------ dialogs
|
|
1382
|
+
/**
|
|
1383
|
+
* Probe a typed path before anything is created. "~/workspace/thing" is what
|
|
1384
|
+
* people type, and a repo with services cloned into a gitignored folder needs
|
|
1385
|
+
* a worktree per service — neither is something to discover after the fact.
|
|
1386
|
+
*/
|
|
1387
|
+
async function probeRepo(prefix) {
|
|
1388
|
+
const input = $(`#${prefix}-repo`)
|
|
1389
|
+
const info = $(`#${prefix}-repo-info`)
|
|
1390
|
+
const list = $(`#${prefix}-services`)
|
|
1391
|
+
const path = input.value.trim()
|
|
1392
|
+
list.hidden = true
|
|
1393
|
+
list.innerHTML = ""
|
|
1394
|
+
if (!path) { info.textContent = ""; return }
|
|
1395
|
+
info.textContent = "checking…"
|
|
1396
|
+
let r
|
|
1397
|
+
try { r = await api("/api/repo?path=" + encodeURIComponent(path)) } catch { info.textContent = ""; return }
|
|
1398
|
+
if (!r.isRepo) {
|
|
1399
|
+
info.innerHTML = `<span style="color:var(--error)">${esc(r.path)} — ${esc(r.reason)}</span>`
|
|
1400
|
+
return
|
|
1401
|
+
}
|
|
1402
|
+
info.innerHTML = `<span class="num">${esc(r.path)}</span>${r.branch ? ` on ${esc(r.branch)}` : ""}` +
|
|
1403
|
+
(r.nested.length ? ` · ${r.nested.length} repositories inside it` : "")
|
|
1404
|
+
if (r.nested.length) {
|
|
1405
|
+
list.hidden = false
|
|
1406
|
+
list.innerHTML =
|
|
1407
|
+
`<label style="color:var(--dim)"><input type="checkbox" id="${prefix}-all" /><span>Give every service its own worktree</span></label>` +
|
|
1408
|
+
r.nested.map((n) => `<label><input type="checkbox" class="svc" value="${esc(n.rel)}" />
|
|
1409
|
+
<span class="num">${esc(n.rel)}</span><span class="d">${esc(n.branch ?? "")}</span></label>`).join("")
|
|
1410
|
+
$(`#${prefix}-all`).onchange = (e) => {
|
|
1411
|
+
for (const c of $$(`#${prefix}-services .svc`)) c.checked = e.target.checked
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
const chosenServices = (prefix) => $$(`#${prefix}-services .svc:checked`).map((c) => c.value)
|
|
1416
|
+
|
|
1417
|
+
const groupOptions = (sel, selected) => {
|
|
1418
|
+
sel.innerHTML = (state.projects ?? []).map((p) => `<option value="${p.id}" ${p.id === selected ? "selected" : ""}>${esc(p.name)}</option>`).join("")
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
function openTaskDialog(projectId) {
|
|
1422
|
+
groupOptions($("#t-project"), projectId ?? (view.kind === "project" ? view.value : "default"))
|
|
1423
|
+
const fill = () => {
|
|
1424
|
+
const chosen = $("#t-project").value
|
|
1425
|
+
const inProject = state.sessions.filter((s) => (s.project ?? "default") === chosen)
|
|
1426
|
+
$("#t-assignee").innerHTML =
|
|
1427
|
+
`<option value="">Nobody yet — leave in the backlog</option>` +
|
|
1428
|
+
inProject.map((s) => `<option value="${s.id}">@${esc(s.handle ?? s.id)} — ${esc(s.name)}</option>`).join("")
|
|
1429
|
+
}
|
|
1430
|
+
$("#t-project").onchange = fill
|
|
1431
|
+
fill()
|
|
1432
|
+
$("#t-error").textContent = ""
|
|
1433
|
+
$("#task-dialog").showModal()
|
|
1434
|
+
}
|
|
1435
|
+
$("#t-cancel").onclick = () => $("#task-dialog").close()
|
|
1436
|
+
$("#t-create").onclick = async () => {
|
|
1437
|
+
const body = {
|
|
1438
|
+
title: $("#t-title").value.trim(),
|
|
1439
|
+
brief: $("#t-brief").value.trim(),
|
|
1440
|
+
projectId: $("#t-project").value,
|
|
1441
|
+
assignee: $("#t-assignee").value || null,
|
|
1442
|
+
}
|
|
1443
|
+
if (!body.title) return ($("#t-error").textContent = "A title is required.")
|
|
1444
|
+
try {
|
|
1445
|
+
await api("/api/tasks", { method: "POST", body })
|
|
1446
|
+
$("#task-dialog").close()
|
|
1447
|
+
$("#t-title").value = ""
|
|
1448
|
+
$("#t-brief").value = ""
|
|
1449
|
+
} catch (e) { $("#t-error").textContent = e.message }
|
|
1450
|
+
}
|
|
1451
|
+
$("#new-task").onclick = () => openTaskDialog()
|
|
1452
|
+
|
|
1453
|
+
$("#add-project").onclick = () => {
|
|
1454
|
+
$("#pj-sessions").innerHTML = state.sessions
|
|
1455
|
+
.map((s) => `<label><input type="checkbox" value="${s.id}" />
|
|
1456
|
+
<img src="${avatarUrl(s)}" style="width:28px;height:28px;border-radius:6px" alt="" />
|
|
1457
|
+
<span class="num">@${esc(s.handle ?? "")}</span><span class="d">${esc(s.name)}</span></label>`)
|
|
1458
|
+
.join("")
|
|
1459
|
+
$("#pj-error").textContent = ""
|
|
1460
|
+
$("#project-dialog").showModal()
|
|
1461
|
+
}
|
|
1462
|
+
$("#pj-cancel").onclick = () => $("#project-dialog").close()
|
|
1463
|
+
$("#pj-create").onclick = async () => {
|
|
1464
|
+
const name = $("#pj-name").value.trim()
|
|
1465
|
+
if (!name) return ($("#pj-error").textContent = "A name is required.")
|
|
1466
|
+
try {
|
|
1467
|
+
const p = await api("/api/projects", {
|
|
1468
|
+
method: "POST",
|
|
1469
|
+
body: { name, repo: $("#pj-repo").value.trim() || null, sessions: $$("#pj-sessions input:checked").map((i) => i.value) },
|
|
1470
|
+
})
|
|
1471
|
+
$("#project-dialog").close()
|
|
1472
|
+
$("#pj-name").value = ""
|
|
1473
|
+
$("#pj-repo").value = ""
|
|
1474
|
+
view = { kind: "project", value: p.id }
|
|
1475
|
+
navSig = null
|
|
1476
|
+
render()
|
|
1477
|
+
} catch (e) { $("#pj-error").textContent = e.message }
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
async function openAdopt() {
|
|
1481
|
+
groupOptions($("#a-group"))
|
|
1482
|
+
await loadLoose("")
|
|
1483
|
+
$("#adopt-dialog").showModal()
|
|
1484
|
+
}
|
|
1485
|
+
async function loadLoose(q) {
|
|
1486
|
+
const { sessions, total } = await api("/api/unmanaged?q=" + encodeURIComponent(q))
|
|
1487
|
+
$("#a-list").innerHTML = sessions.length
|
|
1488
|
+
? sessions.map((s) => `<label><input type="checkbox" value="${s.id}" />
|
|
1489
|
+
<span>${esc(s.title)}</span><span class="d">${esc(fmt.path(s.directory, 2))} · ${fmt.ago(s.updated)}</span></label>`).join("")
|
|
1490
|
+
: `<div class="note" style="padding:10px">Nothing matches. ${total} session(s) known to opencode.</div>`
|
|
1491
|
+
}
|
|
1492
|
+
$("#a-search").oninput = (e) => loadLoose(e.target.value)
|
|
1493
|
+
$("#a-cancel").onclick = () => $("#adopt-dialog").close()
|
|
1494
|
+
$("#a-add").onclick = async () => {
|
|
1495
|
+
const ids = $$("#a-list input:checked").map((i) => i.value)
|
|
1496
|
+
if (!ids.length) return
|
|
1497
|
+
await api("/api/adopt", { method: "POST", body: { ids, group: $("#a-group").value } })
|
|
1498
|
+
$("#adopt-dialog").close()
|
|
1499
|
+
$("#loose").dataset.n = ""
|
|
1500
|
+
}
|
|
1501
|
+
$("#add-existing").onclick = openAdopt
|
|
1502
|
+
|
|
1503
|
+
$("#new-pod").onclick = () => { groupOptions($("#f-group")); $("#new-dialog").showModal() }
|
|
1504
|
+
$("#f-repo").onchange = () => probeRepo("f")
|
|
1505
|
+
$("#f-repo").onblur = () => probeRepo("f")
|
|
1506
|
+
$("#p-repo").onchange = () => probeRepo("p")
|
|
1507
|
+
$("#p-repo").onblur = () => probeRepo("p")
|
|
1508
|
+
$("#f-cancel").onclick = () => $("#new-dialog").close()
|
|
1509
|
+
$("#f-create").onclick = async () => {
|
|
1510
|
+
const body = {
|
|
1511
|
+
repo: $("#f-repo").value.trim(),
|
|
1512
|
+
branch: $("#f-branch").value.trim() || null,
|
|
1513
|
+
task: $("#f-task").value.trim() || null,
|
|
1514
|
+
group: $("#f-group").value,
|
|
1515
|
+
services: chosenServices("f"),
|
|
1516
|
+
}
|
|
1517
|
+
if (!body.repo) return ($("#f-error").textContent = "A repository path is required.")
|
|
1518
|
+
$("#f-create").disabled = true
|
|
1519
|
+
try {
|
|
1520
|
+
await api("/api/sessions", { method: "POST", body })
|
|
1521
|
+
$("#new-dialog").close()
|
|
1522
|
+
$("#f-task").value = ""
|
|
1523
|
+
$("#f-branch").value = ""
|
|
1524
|
+
$("#f-error").textContent = ""
|
|
1525
|
+
} catch (e) {
|
|
1526
|
+
$("#f-error").textContent = e.message
|
|
1527
|
+
}
|
|
1528
|
+
$("#f-create").disabled = false
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
|
|
1532
|
+
let pipelineDefs = { pipelines: [], personas: [], problems: [] }
|
|
1533
|
+
async function openPipelineDialog(project) {
|
|
1534
|
+
pipelineDefs = await api("/api/pipelines")
|
|
1535
|
+
if (project) {
|
|
1536
|
+
$("#p-repo").value = project.repo ?? ""
|
|
1537
|
+
$("#p-branch").value = project.branch ?? ""
|
|
1538
|
+
probeRepo("p")
|
|
1539
|
+
}
|
|
1540
|
+
$("#pipe-dialog").dataset.project = project?.id ?? ""
|
|
1541
|
+
$("#p-pipeline").innerHTML = pipelineDefs.pipelines.map((p) => `<option value="${p.id}">${esc(p.title)}</option>`).join("")
|
|
1542
|
+
showStages()
|
|
1543
|
+
$("#p-error").textContent = pipelineDefs.problems.length
|
|
1544
|
+
? pipelineDefs.problems.map((p) => `${p.file}: ${p.problems.join("; ")}`).join("\n")
|
|
1545
|
+
: ""
|
|
1546
|
+
$("#pipe-dialog").showModal()
|
|
1547
|
+
}
|
|
1548
|
+
function showStages() {
|
|
1549
|
+
const def = pipelineDefs.pipelines.find((p) => p.id === $("#p-pipeline").value)
|
|
1550
|
+
$("#p-stages").textContent = def
|
|
1551
|
+
? def.stages.map((s) => s.persona).join(" → ") + (def.description ? ` — ${def.description}` : "")
|
|
1552
|
+
: ""
|
|
1553
|
+
}
|
|
1554
|
+
$("#p-pipeline").onchange = showStages
|
|
1555
|
+
$("#p-edit").onclick = () => openEditor("pipelines", $("#p-pipeline").value)
|
|
1556
|
+
$("#p-personas").onclick = () => {
|
|
1557
|
+
const id = prompt("Which persona?\n\n" + pipelineDefs.personas.map((x) => x.id).join(", "), pipelineDefs.personas[0]?.id)
|
|
1558
|
+
if (id) openEditor("personas", id)
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
/**
|
|
1562
|
+
* Editing a pipeline in the app and editing the YAML by hand are the same
|
|
1563
|
+
* file. The structured view is for the common edits — add a phase, move it,
|
|
1564
|
+
* change who does it, change what it is handed — and the YAML tab is there
|
|
1565
|
+
* for everything the form does not cover, so the form never becomes a ceiling.
|
|
1566
|
+
*/
|
|
1567
|
+
let editing = null
|
|
1568
|
+
async function openEditor(kind, id) {
|
|
1569
|
+
const def = await api(`/api/${kind}/${id}`)
|
|
1570
|
+
editing = { kind, id, doc: def.doc }
|
|
1571
|
+
$("#ed-title").textContent = `${kind === "personas" ? "Persona" : "Pipeline"}: ${id}`
|
|
1572
|
+
$("#ed-file").textContent = def.file ?? ""
|
|
1573
|
+
$("#ed-error").textContent = ""
|
|
1574
|
+
$("#ed-yaml").hidden = true
|
|
1575
|
+
$("#ed-body").hidden = false
|
|
1576
|
+
$("#ed-yaml-toggle").textContent = "Edit as YAML"
|
|
1577
|
+
renderEditor()
|
|
1578
|
+
$("#edit-dialog").showModal()
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
function renderEditor() {
|
|
1582
|
+
const { kind, doc } = editing
|
|
1583
|
+
const models = [...new Set(state.sessions.map((s) => s.model).filter(Boolean))]
|
|
1584
|
+
if (kind === "personas") {
|
|
1585
|
+
$("#ed-body").innerHTML = `
|
|
1586
|
+
<div class="stagebox">
|
|
1587
|
+
<label>Title</label><input id="e-title" value="${esc(doc.title ?? "")}" style="width:100%" />
|
|
1588
|
+
<label>Role — decides the vocabulary its handle is drawn from (qa, dev, product, reviewer, ops, research, writer)</label>
|
|
1589
|
+
<input id="e-role" value="${esc(doc.role ?? doc.id ?? "")}" style="width:100%" />
|
|
1590
|
+
<label>Model — blank uses the session default</label>
|
|
1591
|
+
<input id="e-model" list="e-models" value="${esc(doc.model ?? "")}" style="width:100%" />
|
|
1592
|
+
<datalist id="e-models">${models.map((m) => `<option value="${esc(m)}">`).join("")}</datalist>
|
|
1593
|
+
<label>Tools it may use — comma separated, blank means everything the session has</label>
|
|
1594
|
+
<input id="e-tools" value="${esc((doc.tools ?? []).join(", "))}" style="width:100%" />
|
|
1595
|
+
<label>Handle adjectives — comma separated, blank uses the role's</label>
|
|
1596
|
+
<input id="e-adj" value="${esc((doc.adjectives ?? []).join(", "))}" style="width:100%" />
|
|
1597
|
+
<div class="recv" style="margin-top:10px">
|
|
1598
|
+
<label><input type="checkbox" id="e-spawn" ${doc.may_spawn ? "checked" : ""} /> may open new sessions</label>
|
|
1599
|
+
<label>peer messages
|
|
1600
|
+
<select id="e-talk">${["open", "inbox", "off"].map((v) => `<option ${(doc.talk ?? "open") === v ? "selected" : ""}>${v}</option>`).join("")}</select></label>
|
|
1601
|
+
<label>group chats
|
|
1602
|
+
<select id="e-rooms">${["member", "create", "off"].map((v) => `<option ${(doc.rooms ?? "member") === v ? "selected" : ""}>${v}</option>`).join("")}</select></label>
|
|
1603
|
+
</div>
|
|
1604
|
+
<label>Brief — the first thing it is told, before any task</label>
|
|
1605
|
+
<textarea id="e-prompt" style="min-height:200px">${esc(doc.prompt ?? "")}</textarea>
|
|
1606
|
+
</div>`
|
|
1607
|
+
return
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
const personas = pipelineDefs.personas.map((p) => p.id)
|
|
1611
|
+
const stages = doc.stages ?? []
|
|
1612
|
+
$("#ed-body").innerHTML = `
|
|
1613
|
+
<div class="stagebox">
|
|
1614
|
+
<label>Title</label><input id="e-title" value="${esc(doc.title ?? "")}" style="width:100%" />
|
|
1615
|
+
<label>Description</label><input id="e-desc" value="${esc(doc.description ?? "")}" style="width:100%" />
|
|
1616
|
+
<label>Shared context — prepended to every bot's brief in this pipeline</label>
|
|
1617
|
+
<textarea id="e-context" style="min-height:60px">${esc(doc.context ?? "")}</textarea>
|
|
1618
|
+
</div>
|
|
1619
|
+
${stages.map((st, i) => `
|
|
1620
|
+
<div class="stagebox" data-i="${i}">
|
|
1621
|
+
<div class="hd">
|
|
1622
|
+
<input class="sid" value="${esc(st.id ?? "")}" data-f="id" />
|
|
1623
|
+
<select data-f="persona" ${st.human ? "disabled" : ""}>
|
|
1624
|
+
${personas.map((x) => `<option ${x === st.persona ? "selected" : ""}>${esc(x)}</option>`).join("")}
|
|
1625
|
+
</select>
|
|
1626
|
+
<label style="margin:0;color:var(--dim);font-size:11.5px;white-space:nowrap;display:inline-flex;align-items:center;gap:5px"><input type="checkbox" data-f="human" style="width:auto" ${st.human ? "checked" : ""} /> yours, not a bot's</label>
|
|
1627
|
+
<span class="sp">
|
|
1628
|
+
<button class="ghost tiny" data-move="-1" ${i === 0 ? "disabled" : ""}>↑</button>
|
|
1629
|
+
<button class="ghost tiny" data-move="1" ${i === stages.length - 1 ? "disabled" : ""}>↓</button>
|
|
1630
|
+
<button class="ghost tiny danger" data-del="1">Remove</button>
|
|
1631
|
+
</span>
|
|
1632
|
+
</div>
|
|
1633
|
+
<label>Phase title</label><input value="${esc(st.title ?? "")}" data-f="title" style="width:100%" />
|
|
1634
|
+
<label>Receives — which earlier phases' handoffs this one is given</label>
|
|
1635
|
+
<div class="recv">${
|
|
1636
|
+
stages.filter((o, j) => j < i).map((o) => `<label><input type="checkbox" data-recv="${esc(o.id)}" ${(st.receives ?? []).includes(o.id) ? "checked" : ""} /> ${esc(o.id)}</label>`).join("")
|
|
1637
|
+
|| `<span class="note" style="margin:0">nothing before it</span>`
|
|
1638
|
+
}</div>
|
|
1639
|
+
<label>Prompt — mustache over story, acceptance_criteria and handoffs.<phase></label>
|
|
1640
|
+
<textarea data-f="prompt">${esc(st.prompt ?? "")}</textarea>
|
|
1641
|
+
</div>`).join("")}
|
|
1642
|
+
<button class="ghost" id="e-add">+ Add a phase</button>`
|
|
1643
|
+
|
|
1644
|
+
const read = () => {
|
|
1645
|
+
doc.title = $("#e-title").value
|
|
1646
|
+
doc.description = $("#e-desc").value
|
|
1647
|
+
doc.context = $("#e-context").value || undefined
|
|
1648
|
+
doc.stages = $$("#ed-body .stagebox[data-i]").map((box) => {
|
|
1649
|
+
const g = (f) => $(`[data-f="${f}"]`, box)
|
|
1650
|
+
return {
|
|
1651
|
+
id: g("id").value.trim(),
|
|
1652
|
+
persona: g("human").checked ? undefined : g("persona").value,
|
|
1653
|
+
human: g("human").checked || undefined,
|
|
1654
|
+
title: g("title").value.trim() || undefined,
|
|
1655
|
+
receives: $$("[data-recv]", box).filter((c) => c.checked).map((c) => c.dataset.recv),
|
|
1656
|
+
prompt: g("prompt").value || undefined,
|
|
1657
|
+
}
|
|
1658
|
+
})
|
|
1659
|
+
}
|
|
1660
|
+
editing.read = read
|
|
1661
|
+
for (const box of $$("#ed-body .stagebox[data-i]")) {
|
|
1662
|
+
const i = Number(box.dataset.i)
|
|
1663
|
+
$("[data-move='-1']", box).onclick = () => { read(); const [x] = doc.stages.splice(i, 1); doc.stages.splice(i - 1, 0, x); renderEditor() }
|
|
1664
|
+
$("[data-move='1']", box).onclick = () => { read(); const [x] = doc.stages.splice(i, 1); doc.stages.splice(i + 1, 0, x); renderEditor() }
|
|
1665
|
+
$("[data-del]", box).onclick = () => { read(); doc.stages.splice(i, 1); renderEditor() }
|
|
1666
|
+
$("[data-f='human']", box).onchange = () => { read(); renderEditor() }
|
|
1667
|
+
}
|
|
1668
|
+
$("#e-add").onclick = () => {
|
|
1669
|
+
read()
|
|
1670
|
+
doc.stages.push({ id: "phase" + (doc.stages.length + 1), persona: personas[0], receives: doc.stages.map((x) => x.id), prompt: "{{story}}" })
|
|
1671
|
+
renderEditor()
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
$("#ed-yaml-toggle").onclick = async () => {
|
|
1676
|
+
const showingYaml = !$("#ed-yaml").hidden
|
|
1677
|
+
if (showingYaml) {
|
|
1678
|
+
$("#ed-yaml").hidden = true
|
|
1679
|
+
$("#ed-body").hidden = false
|
|
1680
|
+
$("#ed-yaml-toggle").textContent = "Edit as YAML"
|
|
1681
|
+
} else {
|
|
1682
|
+
editing.read?.()
|
|
1683
|
+
if (editing.kind === "personas") readPersona()
|
|
1684
|
+
const def = await api(`/api/${editing.kind}/${editing.id}`)
|
|
1685
|
+
$("#ed-yaml").value = def.yaml
|
|
1686
|
+
$("#ed-yaml").hidden = false
|
|
1687
|
+
$("#ed-body").hidden = true
|
|
1688
|
+
$("#ed-yaml-toggle").textContent = "Back to the form"
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
function readPersona() {
|
|
1692
|
+
const d = editing.doc
|
|
1693
|
+
d.title = $("#e-title").value
|
|
1694
|
+
d.role = $("#e-role").value.trim() || undefined
|
|
1695
|
+
d.model = $("#e-model").value.trim() || undefined
|
|
1696
|
+
d.tools = $("#e-tools").value.split(",").map((x) => x.trim()).filter(Boolean)
|
|
1697
|
+
d.adjectives = $("#e-adj").value.split(",").map((x) => x.trim()).filter(Boolean)
|
|
1698
|
+
if (!d.tools.length) delete d.tools
|
|
1699
|
+
if (!d.adjectives.length) delete d.adjectives
|
|
1700
|
+
d.may_spawn = $("#e-spawn").checked || undefined
|
|
1701
|
+
d.talk = $("#e-talk").value
|
|
1702
|
+
d.rooms = $("#e-rooms").value
|
|
1703
|
+
d.prompt = $("#e-prompt").value
|
|
1704
|
+
}
|
|
1705
|
+
$("#ed-cancel").onclick = () => $("#edit-dialog").close()
|
|
1706
|
+
$("#ed-save").onclick = async () => {
|
|
1707
|
+
try {
|
|
1708
|
+
let body
|
|
1709
|
+
if (!$("#ed-yaml").hidden) body = { yaml: $("#ed-yaml").value }
|
|
1710
|
+
else {
|
|
1711
|
+
if (editing.kind === "personas") readPersona()
|
|
1712
|
+
else editing.read?.()
|
|
1713
|
+
body = { doc: editing.doc }
|
|
1714
|
+
}
|
|
1715
|
+
const saved = await api(`/api/${editing.kind}/${editing.id}`, { method: "PUT", body })
|
|
1716
|
+
$("#ed-error").textContent = saved.problems?.length
|
|
1717
|
+
? "Saved, but: " + saved.problems.join("\n")
|
|
1718
|
+
: ""
|
|
1719
|
+
pipelineDefs = await api("/api/pipelines")
|
|
1720
|
+
showStages()
|
|
1721
|
+
if (!saved.problems?.length) $("#edit-dialog").close()
|
|
1722
|
+
} catch (e) {
|
|
1723
|
+
$("#ed-error").textContent = e.message
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
$("#p-cancel").onclick = () => $("#pipe-dialog").close()
|
|
1727
|
+
$("#p-reload").onclick = async () => { await api("/api/pipelines/reload", { method: "POST" }); openPipelineDialog() }
|
|
1728
|
+
$("#p-run").onclick = async () => {
|
|
1729
|
+
const body = {
|
|
1730
|
+
pipeline: $("#p-pipeline").value,
|
|
1731
|
+
repo: $("#p-repo").value.trim(),
|
|
1732
|
+
branch: $("#p-branch").value.trim() || null,
|
|
1733
|
+
story: $("#p-story").value.trim(),
|
|
1734
|
+
services: chosenServices("p"),
|
|
1735
|
+
project: $("#pipe-dialog").dataset.project || undefined,
|
|
1736
|
+
}
|
|
1737
|
+
if (!body.repo || !body.story) return ($("#p-error").textContent = "A repository and a story are both required.")
|
|
1738
|
+
$("#p-run").disabled = true
|
|
1739
|
+
try {
|
|
1740
|
+
await api("/api/runs", { method: "POST", body })
|
|
1741
|
+
$("#pipe-dialog").close()
|
|
1742
|
+
$("#p-story").value = ""
|
|
1743
|
+
view = { kind: "board", value: null }
|
|
1744
|
+
navSig = null
|
|
1745
|
+
render()
|
|
1746
|
+
} catch (e) { $("#p-error").textContent = e.message }
|
|
1747
|
+
$("#p-run").disabled = false
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
$("#filter").oninput = (e) => { filterText = e.target.value.toLowerCase(); render() }
|
|
1751
|
+
$("#abort-all").onclick = async () => {
|
|
1752
|
+
const running = visibleSessions().filter((s) => s.status === "busy")
|
|
1753
|
+
if (!running.length || !confirm(`Abort ${running.length} running session(s)?`)) return
|
|
1754
|
+
await Promise.all(running.map((s) => api(`/api/sessions/${s.id}/abort`, { method: "POST" })))
|
|
1755
|
+
}
|
|
1756
|
+
document.addEventListener("keydown", (e) => {
|
|
1757
|
+
if (e.key === "Escape" && (openId || roomId)) closeModal()
|
|
1758
|
+
if (e.key === "/" && !["INPUT", "TEXTAREA"].includes(document.activeElement.tagName)) { e.preventDefault(); $("#filter").focus() }
|
|
1759
|
+
})
|
|
1760
|
+
|
|
1761
|
+
/**
|
|
1762
|
+
* Websocket first, EventSource if it will not connect. Both are pushes — the
|
|
1763
|
+
* dashboard has never polled — but the socket is cheaper per frame and lets us
|
|
1764
|
+
* ask for a resync after a reconnect instead of waiting for the next change.
|
|
1765
|
+
*/
|
|
1766
|
+
function connect() {
|
|
1767
|
+
let ws
|
|
1768
|
+
let fellBack = false
|
|
1769
|
+
const fallback = () => {
|
|
1770
|
+
if (fellBack) return
|
|
1771
|
+
fellBack = true
|
|
1772
|
+
const es = new EventSource("/api/stream")
|
|
1773
|
+
es.onmessage = (e) => { state = JSON.parse(e.data); render(true) }
|
|
1774
|
+
es.onerror = () => { $("#conn").textContent = "dashboard offline" }
|
|
1775
|
+
}
|
|
1776
|
+
const open = () => {
|
|
1777
|
+
try {
|
|
1778
|
+
ws = new WebSocket(`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/api/socket`)
|
|
1779
|
+
} catch {
|
|
1780
|
+
return fallback()
|
|
1781
|
+
}
|
|
1782
|
+
const guard = setTimeout(() => { if (ws.readyState !== 1) { try { ws.close() } catch {} ; fallback() } }, 2500)
|
|
1783
|
+
ws.onopen = () => {
|
|
1784
|
+
clearTimeout(guard)
|
|
1785
|
+
sock = ws
|
|
1786
|
+
ws.send("resync")
|
|
1787
|
+
if (openId) ws.send(`watch:${openId}`)
|
|
1788
|
+
}
|
|
1789
|
+
ws.onmessage = (e) => {
|
|
1790
|
+
const msg = JSON.parse(e.data)
|
|
1791
|
+
if (msg.t) return onLive(msg)
|
|
1792
|
+
state = msg
|
|
1793
|
+
render(true)
|
|
1794
|
+
}
|
|
1795
|
+
ws.onclose = () => {
|
|
1796
|
+
clearTimeout(guard)
|
|
1797
|
+
if (fellBack) return
|
|
1798
|
+
$("#conn").textContent = "reconnecting…"
|
|
1799
|
+
setTimeout(open, 1500)
|
|
1800
|
+
}
|
|
1801
|
+
ws.onerror = () => { if (ws.readyState !== 1) fallback() }
|
|
1802
|
+
}
|
|
1803
|
+
open()
|
|
1804
|
+
}
|
|
1805
|
+
connect()
|
|
1806
|
+
// Re-render on a tick only to keep the relative clocks honest; no network.
|
|
1807
|
+
setInterval(() => render(), 1000)
|
|
1808
|
+
</script>
|
|
1809
|
+
</body>
|
|
1810
|
+
</html>
|