@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,3284 @@
|
|
|
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 amber / violet / blue, never red-versus-green; red is only
|
|
13
|
+
failure. Every state is also written in words. */
|
|
14
|
+
/* Neutral graphite rather than navy, with brighter text and edges: every
|
|
15
|
+
pair here clears WCAG AA against the surface it sits on. */
|
|
16
|
+
--ink: #141619; --ink-2: #1a1d21; --panel: #22262b; --panel-2: #2b3036; --line: #3d434b; --line-2: #545c66;
|
|
17
|
+
--text: #f7f8fa; --dim: #cfd5dd; --faint: #9aa3ae;
|
|
18
|
+
--busy: #ffb547; --wait: #c3a6ff; --error: #ff7b6e; --idle: #98a4b3; --ok: #5ccfff;
|
|
19
|
+
--accent: #3d8bff; --accent-2: #2f74e0; --hover: #353b43;
|
|
20
|
+
--radius: 6px;
|
|
21
|
+
--mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
22
|
+
--sans: "IBM Plex Sans", ui-sans-serif, system-ui, -apple-system, sans-serif;
|
|
23
|
+
}
|
|
24
|
+
* { box-sizing: border-box; }
|
|
25
|
+
[hidden] { display: none !important; }
|
|
26
|
+
html, body { margin: 0; height: 100%; }
|
|
27
|
+
body { background: var(--ink); color: var(--text); font-family: var(--sans); font-size: 14px; line-height: 1.45; -webkit-font-smoothing: antialiased; overflow: hidden; }
|
|
28
|
+
.mono, .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-2); border-radius: var(--radius); padding: 6px 10px; }
|
|
30
|
+
button { cursor: pointer; transition: background .12s, border-color .12s, color .12s; }
|
|
31
|
+
button:hover { background: var(--hover); }
|
|
32
|
+
button:disabled { opacity: .5; cursor: default; }
|
|
33
|
+
:focus-visible { outline: 2px solid var(--wait); outline-offset: 1px; }
|
|
34
|
+
input::placeholder, textarea::placeholder { color: var(--faint); }
|
|
35
|
+
textarea { resize: vertical; }
|
|
36
|
+
.ghost { background: transparent; border-color: transparent; color: var(--dim); }
|
|
37
|
+
.ghost:hover { color: var(--text); background: var(--panel-2); }
|
|
38
|
+
.primary { background: var(--accent-2); border-color: var(--accent); color: #fff; }
|
|
39
|
+
.primary:hover { background: var(--accent); }
|
|
40
|
+
.danger:hover { background: #3a1f20; border-color: #6b2f2c; color: #ffb3ab; }
|
|
41
|
+
.sm { font-size: 12px; padding: 3px 9px; }
|
|
42
|
+
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--idle); flex: none; }
|
|
43
|
+
.dot.busy { background: var(--busy); } .dot.waiting { background: var(--wait); } .dot.error { background: var(--error); } .dot.done { background: var(--ok); }
|
|
44
|
+
.note { color: var(--faint); font-size: 12.5px; }
|
|
45
|
+
.err { color: var(--error); font-size: 12.5px; white-space: pre-wrap; }
|
|
46
|
+
.chip { display: inline-flex; align-items: center; gap: 5px; font-family: var(--mono); font-size: 11px; color: var(--dim); border: 1px solid var(--line-2); border-radius: 20px; padding: 1px 8px; white-space: nowrap; }
|
|
47
|
+
|
|
48
|
+
/* ---------- layout ---------- */
|
|
49
|
+
.app { display: grid; grid-template-columns: 250px 1fr; height: 100vh; }
|
|
50
|
+
aside.side { background: var(--ink-2); border-right: 1px solid var(--line); display: flex; flex-direction: column; min-height: 0; }
|
|
51
|
+
.brand { display: flex; align-items: center; gap: 8px; padding: 14px 16px 10px; }
|
|
52
|
+
.brand b { font-family: var(--mono); font-size: 15px; letter-spacing: .02em; }
|
|
53
|
+
.brand .conn { margin-left: auto; font-family: var(--mono); font-size: 10.5px; color: var(--faint); }
|
|
54
|
+
.wspick { margin: 0 10px 8px; display: flex; flex-direction: column; align-items: stretch; text-align: left; background: var(--panel); border: 1px solid var(--line); padding: 8px 10px; }
|
|
55
|
+
.wspick .n { font-weight: 600; display: flex; align-items: center; gap: 6px; }
|
|
56
|
+
.wspick .n::after { content: "▾"; margin-left: auto; color: var(--faint); font-size: 11px; }
|
|
57
|
+
.wspick .p { font-family: var(--mono); font-size: 11px; color: var(--faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: rtl; text-align: left; }
|
|
58
|
+
.wsmenu { margin: -4px 10px 8px; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 4px; }
|
|
59
|
+
.wsmenu button { display: block; width: 100%; text-align: left; border: 0; background: transparent; padding: 6px 8px; }
|
|
60
|
+
.wsmenu button:hover { background: var(--panel-2); }
|
|
61
|
+
.wsmenu button[aria-current="true"] { color: var(--ok); }
|
|
62
|
+
.nav { flex: 1; overflow-y: auto; padding: 4px 0 12px; }
|
|
63
|
+
.nav h4 { display: flex; align-items: center; margin: 14px 16px 4px; font-size: 10.5px; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; color: var(--faint); }
|
|
64
|
+
.nav h4 button { margin-left: auto; padding: 0 6px; font-size: 15px; line-height: 18px; }
|
|
65
|
+
.item { display: flex; align-items: center; gap: 8px; width: calc(100% - 16px); margin: 1px 8px; padding: 5px 8px; border: 0; background: transparent; text-align: left; border-radius: var(--radius); color: var(--dim); min-width: 0; }
|
|
66
|
+
.item:hover { background: var(--panel); color: var(--text); }
|
|
67
|
+
.item[aria-current="true"] { background: var(--panel-2); color: var(--text); }
|
|
68
|
+
.item .t { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
69
|
+
.item .hash { color: var(--faint); font-family: var(--mono); }
|
|
70
|
+
.item .badge { font-family: var(--mono); font-size: 10.5px; background: var(--wait); color: #140d2a; border-radius: 10px; padding: 0 6px; font-weight: 600; }
|
|
71
|
+
.item .badge.msg { background: var(--ok); color: #061825; }
|
|
72
|
+
.item.archived { opacity: .55; }
|
|
73
|
+
.foot { border-top: 1px solid var(--line); padding: 10px 16px; display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px 4px; }
|
|
74
|
+
.foot .spend { display: flex; flex-direction: column; align-items: flex-start; background: none; border: 0; padding: 0; text-align: left; }
|
|
75
|
+
.foot .spend i.warn { color: var(--busy); text-decoration: underline dotted; }
|
|
76
|
+
.foot div { display: flex; flex-direction: column; }
|
|
77
|
+
.foot b { font-family: var(--mono); font-size: 13px; font-weight: 500; }
|
|
78
|
+
.foot i { font-style: normal; font-size: 10.5px; color: var(--faint); }
|
|
79
|
+
.foot a { grid-column: 1 / -1; font-size: 11px; color: var(--faint); margin-top: 6px; }
|
|
80
|
+
main { min-width: 0; min-height: 0; display: flex; flex-direction: column; }
|
|
81
|
+
|
|
82
|
+
/* ---------- workstream ---------- */
|
|
83
|
+
.whead { padding: 14px 20px 12px; border-bottom: 1px solid var(--line); display: flex; flex-direction: column; gap: 10px; }
|
|
84
|
+
.whead .row1 { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
|
85
|
+
.whead h1 { font-size: 18px; font-weight: 600; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
86
|
+
.whead .acts { margin-left: auto; display: flex; gap: 6px; flex: none; }
|
|
87
|
+
.whead .row2 { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
|
|
88
|
+
.steps { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
|
|
89
|
+
.step { font-family: var(--mono); font-size: 11.5px; padding: 2px 9px; border-radius: 20px; border: 1px solid var(--line-2); color: var(--faint); }
|
|
90
|
+
.step.done { color: var(--ok); border-color: #2c6a8c; }
|
|
91
|
+
.step.active { color: var(--busy); border-color: var(--busy); }
|
|
92
|
+
.step.needs { color: var(--wait); border-color: var(--wait); }
|
|
93
|
+
.steps .arr { color: var(--line-2); font-size: 11px; }
|
|
94
|
+
.team { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
95
|
+
.bot { display: flex; align-items: center; gap: 10px; padding: 4px 14px 4px 4px; border-radius: 30px; border: 1px solid var(--line); background: var(--panel); cursor: pointer; max-width: 300px; min-width: 0; }
|
|
96
|
+
.bot:hover { border-color: var(--line-2); }
|
|
97
|
+
.bot img { width: 40px; height: 40px; border-radius: 50%; flex: none; }
|
|
98
|
+
.bot .who { display: flex; flex-direction: column; min-width: 0; line-height: 1.2; }
|
|
99
|
+
.bot .h { font-family: var(--mono); font-size: 13px; }
|
|
100
|
+
.bot .d { font-size: 11px; color: var(--faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
101
|
+
.bot { max-width: 420px; align-items: flex-start; border-radius: 22px; padding: 6px 14px 6px 6px; }
|
|
102
|
+
.bot .top { display: flex; align-items: baseline; gap: 7px; min-width: 0; }
|
|
103
|
+
.bot .kind { font-size: 11px; color: var(--fg); border: 1px solid color-mix(in srgb, var(--fg) 45%, transparent); border-radius: 10px; padding: 0 7px; white-space: nowrap; }
|
|
104
|
+
.bot .model { font-family: var(--mono); font-size: 11px; color: var(--dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
105
|
+
.bot .stats { display: flex; gap: 10px; font-family: var(--mono); font-size: 10.5px; color: var(--faint); margin-top: 2px; flex-wrap: wrap; }
|
|
106
|
+
.bot .stats b { color: var(--dim); font-weight: 500; }
|
|
107
|
+
.bot .stats .bad { color: var(--error); }
|
|
108
|
+
.bot.error .d { color: var(--error); white-space: normal; }
|
|
109
|
+
.bot.busy { border-color: var(--busy); } .bot.busy .h { color: var(--busy); }
|
|
110
|
+
.bot.waiting { border-color: var(--wait); } .bot.error { border-color: var(--error); }
|
|
111
|
+
/* Board above, conversation below, full width; drag the bar between them. */
|
|
112
|
+
.wbody { flex: 1; min-height: 0; display: grid; grid-template-rows: var(--split, 44%) 8px 1fr; }
|
|
113
|
+
.board { display: grid; grid-template-columns: repeat(4, minmax(200px, 1fr)); gap: 10px; padding: 14px; overflow: auto; align-content: start; min-height: 0; }
|
|
114
|
+
.splitter { cursor: row-resize; background: var(--ink-2); border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); display: grid; place-items: center; }
|
|
115
|
+
.splitter::after { content: ""; width: 44px; height: 3px; border-radius: 2px; background: var(--line-2); }
|
|
116
|
+
.splitter:hover::after, .splitter.drag::after { background: var(--ok); }
|
|
117
|
+
.col { background: var(--ink-2); border: 1px solid var(--line); border-radius: 8px; display: flex; flex-direction: column; min-height: 120px; }
|
|
118
|
+
.col h5 { margin: 0; padding: 9px 12px; display: flex; align-items: center; font-size: 11px; text-transform: uppercase; letter-spacing: .07em; color: var(--dim); }
|
|
119
|
+
.col h5 span { margin-left: auto; font-family: var(--mono); color: var(--faint); }
|
|
120
|
+
.col.needs h5 { color: var(--wait); } .col.doing h5 { color: var(--busy); }
|
|
121
|
+
.col .cards { display: flex; flex-direction: column; gap: 7px; padding: 0 8px 10px; flex: 1; }
|
|
122
|
+
.col.over { border-color: var(--ok); background: #1d2a35; }
|
|
123
|
+
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 9px 10px; cursor: pointer; display: flex; flex-direction: column; gap: 6px; }
|
|
124
|
+
.card:hover { border-color: var(--line-2); }
|
|
125
|
+
.card.dragging { opacity: .4; }
|
|
126
|
+
.card .ttl { font-size: 13px; }
|
|
127
|
+
.card .meta { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--faint); min-width: 0; }
|
|
128
|
+
.card .meta img { width: 30px; height: 30px; border-radius: 50%; flex: none; }
|
|
129
|
+
.card .meta { gap: 8px; }
|
|
130
|
+
.card .meta .h { font-size: 12px; color: var(--dim); }
|
|
131
|
+
.card .meta .h { font-family: var(--mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
132
|
+
.card .meta .who { display: flex; flex-direction: column; min-width: 0; line-height: 1.3; }
|
|
133
|
+
.card .meta .st { font-family: var(--mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
134
|
+
.card.active { border-left: 3px solid var(--busy); }
|
|
135
|
+
.card.question, .card.review { border-left: 3px solid var(--wait); }
|
|
136
|
+
.card .stage { font-family: var(--mono); font-size: 10.5px; color: var(--wait); }
|
|
137
|
+
.addcard { border: 1px dashed var(--line-2); background: transparent; color: var(--faint); font-size: 12px; }
|
|
138
|
+
.chat { display: flex; flex-direction: column; min-height: 0; background: var(--ink-2); }
|
|
139
|
+
.chat .ch { padding: 10px 14px; border-bottom: 1px solid var(--line); display: flex; align-items: center; gap: 8px; }
|
|
140
|
+
.chat .ch b { font-family: var(--mono); font-size: 13px; }
|
|
141
|
+
.log { flex: 1; overflow-y: auto; overflow-x: hidden; padding: 12px 20px; display: flex; flex-direction: column; gap: 12px; }
|
|
142
|
+
/* Each bot's messages wear its avatar's colours; yours sit on the right. */
|
|
143
|
+
.log { gap: 4px; }
|
|
144
|
+
.msg { display: flex; gap: 12px; align-items: flex-start; margin-top: 10px; --fg: var(--dim); --bg: var(--panel-2); }
|
|
145
|
+
.msg.cont { margin-top: 0; }
|
|
146
|
+
.msg .av { width: 44px; height: 44px; border-radius: 10px; flex: none; }
|
|
147
|
+
.msg.cont .av { visibility: hidden; height: 0; }
|
|
148
|
+
.msg .c { min-width: 0; max-width: min(900px, 85%); display: flex; flex-direction: column; align-items: flex-start; }
|
|
149
|
+
.msg .b.long { max-height: 11em; overflow: hidden; -webkit-mask-image: linear-gradient(#000 65%, transparent); mask-image: linear-gradient(#000 65%, transparent); }
|
|
150
|
+
.msg .more { font-family: var(--mono); font-size: 11px; color: var(--faint); background: none; border: 0; padding: 2px 4px; }
|
|
151
|
+
.msg .more:hover { color: var(--text); background: none; }
|
|
152
|
+
.msg .m { display: flex; align-items: baseline; gap: 6px; font-size: 11px; color: var(--faint); margin: 0 0 3px 2px; }
|
|
153
|
+
.msg .m b { font-family: var(--mono); font-weight: 600; font-size: 12px; color: var(--fg); }
|
|
154
|
+
.msg .m .role { font-family: var(--mono); font-size: 10.5px; color: var(--faint); }
|
|
155
|
+
.msg.cont .m { display: none; }
|
|
156
|
+
.msg .b { font-size: 13.5px; white-space: pre-wrap; word-break: break-word; padding: 7px 11px; border-radius: 4px 12px 12px 12px;
|
|
157
|
+
background: color-mix(in srgb, var(--bg) 62%, var(--panel)); border: 1px solid color-mix(in srgb, var(--fg) 38%, transparent); }
|
|
158
|
+
.msg.cont .b { border-radius: 12px; }
|
|
159
|
+
.msg .b .at { font-family: var(--mono); font-size: 12.5px; font-weight: 600; color: var(--atc, var(--ok)); }
|
|
160
|
+
.msg.op { flex-direction: row-reverse; --fg: var(--ok); --bg: #1c4461; }
|
|
161
|
+
.msg.op .c { align-items: flex-end; }
|
|
162
|
+
.msg.op .m { margin: 0 2px 3px 0; }
|
|
163
|
+
.msg.op .b { border-radius: 12px 4px 12px 12px; }
|
|
164
|
+
.msg.op .av { display: grid; place-items: center; background: #1c4461; border: 1px solid #3a7aa6; font-size: 11px; font-family: var(--mono); color: var(--ok); }
|
|
165
|
+
.msg .b .tag { display: inline-block; font-family: var(--mono); font-size: 10.5px; color: var(--fg); border: 1px solid color-mix(in srgb, var(--fg) 45%, transparent); border-radius: 10px; padding: 0 7px; margin-bottom: 4px; }
|
|
166
|
+
.ask { background: var(--ink-2); border: 1px solid var(--wait); border-radius: 10px; padding: 10px 12px; display: flex; flex-direction: column; gap: 12px; }
|
|
167
|
+
.ask .askh { font-size: 12.5px; color: var(--dim); }
|
|
168
|
+
.ask .askh b { color: var(--wait); font-family: var(--mono); font-weight: 600; }
|
|
169
|
+
.q { display: flex; flex-direction: column; gap: 6px; }
|
|
170
|
+
.q .qh { align-self: flex-start; font-family: var(--mono); font-size: 10.5px; color: var(--wait); border: 1px solid #4a3d7a; border-radius: 10px; padding: 0 7px; }
|
|
171
|
+
.q .qt { font-size: 13.5px; white-space: pre-wrap; display: flex; gap: 7px; }
|
|
172
|
+
.q .qn { font-family: var(--mono); font-size: 11px; color: #140d2a; background: var(--wait); border-radius: 50%; width: 18px; height: 18px; flex: none; display: grid; place-items: center; margin-top: 1px; }
|
|
173
|
+
.opts { display: flex; flex-direction: column; gap: 4px; }
|
|
174
|
+
.opt { display: flex; gap: 9px; align-items: flex-start; text-align: left; background: var(--panel); border-color: var(--line); padding: 6px 10px; }
|
|
175
|
+
.opt:hover { border-color: var(--line-2); }
|
|
176
|
+
.opt .box { width: 14px; height: 14px; border: 1.5px solid var(--faint); border-radius: 50%; flex: none; margin-top: 3px; }
|
|
177
|
+
.opt[role=checkbox] .box { border-radius: 3px; }
|
|
178
|
+
.opt[aria-checked=true] { border-color: var(--wait); background: #221a3d; }
|
|
179
|
+
.opt[aria-checked=true] .box { border-color: var(--wait); background: var(--wait); box-shadow: inset 0 0 0 2.5px #221a3d; }
|
|
180
|
+
.opt .ol { display: flex; flex-direction: column; }
|
|
181
|
+
.opt .ol b { font-weight: 500; font-size: 13px; }
|
|
182
|
+
.opt .ol i { font-style: normal; font-size: 12px; color: var(--faint); }
|
|
183
|
+
.q .qa, .ask .qnote { width: 100%; font-size: 13px; resize: none; }
|
|
184
|
+
.ask .askf { display: flex; align-items: center; gap: 8px; }
|
|
185
|
+
.ask .askf .note { margin-right: auto; }
|
|
186
|
+
.log .ask { margin: 12px 0 4px 56px; max-width: 760px; }
|
|
187
|
+
.bot { --fg: var(--line-2); }
|
|
188
|
+
.bot img { box-shadow: 0 0 0 2px var(--fg); }
|
|
189
|
+
.sys { font-family: var(--mono); font-size: 11px; color: var(--faint); padding-left: 56px; margin: 2px 0; }
|
|
190
|
+
.sys.warn { color: var(--busy); } .sys.error { color: var(--error); }
|
|
191
|
+
.composer { border-top: 1px solid var(--line); padding: 10px; display: flex; gap: 8px; position: relative; }
|
|
192
|
+
.composer textarea { flex: 1; min-height: 40px; max-height: 160px; resize: none; font-size: 13px; }
|
|
193
|
+
.mentions { position: absolute; bottom: 100%; left: 10px; background: var(--panel); border: 1px solid var(--line-2); border-radius: var(--radius); padding: 4px; min-width: 200px; }
|
|
194
|
+
.mentions button { display: flex; gap: 8px; align-items: center; width: 100%; border: 0; background: transparent; text-align: left; font-family: var(--mono); font-size: 12px; }
|
|
195
|
+
.mentions button[aria-selected="true"], .mentions button:hover { background: var(--panel-2); }
|
|
196
|
+
.mentions img { width: 24px; height: 24px; border-radius: 50%; }
|
|
197
|
+
|
|
198
|
+
/* ---------- home / editors ---------- */
|
|
199
|
+
.page { flex: 1; overflow-y: auto; padding: 22px 26px 60px; }
|
|
200
|
+
.page h1 { font-size: 20px; margin: 0 0 4px; }
|
|
201
|
+
.page .lead { color: var(--dim); margin: 0 0 18px; }
|
|
202
|
+
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; }
|
|
203
|
+
.tile { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; cursor: pointer; display: flex; flex-direction: column; gap: 8px; text-align: left; }
|
|
204
|
+
.tile:hover { border-color: var(--line-2); background: var(--panel); }
|
|
205
|
+
.tile .top { display: flex; align-items: center; gap: 8px; }
|
|
206
|
+
.tile .top b { font-weight: 600; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
207
|
+
.tile .last { font-size: 12.5px; color: var(--dim); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
|
208
|
+
.tile .avs { display: flex; gap: 3px; }
|
|
209
|
+
.tile .avs img { width: 32px; height: 32px; border-radius: 50%; }
|
|
210
|
+
.hero { border: 1px dashed var(--line-2); border-radius: 10px; padding: 26px; text-align: center; color: var(--dim); margin-bottom: 18px; }
|
|
211
|
+
.hero .primary { margin-top: 10px; font-size: 14px; padding: 8px 16px; }
|
|
212
|
+
.edhead { display: flex; align-items: center; gap: 10px; margin-bottom: 4px; flex-wrap: wrap; }
|
|
213
|
+
.edhead .acts { margin-left: auto; display: flex; gap: 6px; }
|
|
214
|
+
.field { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; }
|
|
215
|
+
.field label { font-size: 11.5px; color: var(--dim); font-weight: 500; }
|
|
216
|
+
.field .hint { font-size: 11.5px; color: var(--faint); }
|
|
217
|
+
.row { display: flex; gap: 10px; }
|
|
218
|
+
.row > .field { flex: 1; min-width: 0; }
|
|
219
|
+
textarea.code, .code { font-family: var(--mono); font-size: 12.5px; line-height: 1.5; }
|
|
220
|
+
.stages { display: flex; flex-direction: column; gap: 0; max-width: 900px; }
|
|
221
|
+
.stagecard { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; }
|
|
222
|
+
.stagecard.human { border-color: #4a3d7a; }
|
|
223
|
+
.stagecard .sh { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
|
|
224
|
+
.stagecard .sh .n { font-family: var(--mono); color: var(--faint); font-size: 12px; }
|
|
225
|
+
.stagecard .sh .tools { margin-left: auto; display: flex; gap: 4px; }
|
|
226
|
+
.link { align-self: center; color: var(--line-2); font-size: 16px; line-height: 22px; }
|
|
227
|
+
.toggles { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
228
|
+
.toggle { font-family: var(--mono); font-size: 11.5px; padding: 2px 9px; border-radius: 20px; }
|
|
229
|
+
.toggle[aria-pressed="true"] { background: #1c3a55; border-color: var(--ok); color: var(--ok); }
|
|
230
|
+
.problems { background: #2a1d24; border: 1px solid #5c2f36; border-radius: 6px; padding: 8px 12px; margin: 10px 0; font-size: 12.5px; color: #ffc2bb; max-width: 900px; }
|
|
231
|
+
.tabs { display: flex; gap: 4px; margin: 12px 0 14px; }
|
|
232
|
+
/* difficulty, XP, levels, hats */
|
|
233
|
+
.card .tags { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
234
|
+
.diff { font-family: var(--mono); font-size: 10.5px; border-radius: 10px; padding: 0 7px; border: 1px solid var(--line-2); color: var(--dim); }
|
|
235
|
+
.diff.d-easy { color: #8fe0a0; border-color: #3d7a4d; } .diff.d-hard { color: #ffb0a6; border-color: #8a4038; }
|
|
236
|
+
.xp { font-family: var(--mono); font-size: 10.5px; color: #ffd66b; }
|
|
237
|
+
.rbot .ava .hat { position: absolute; top: -13px; left: 50%; transform: translateX(-50%) rotate(-8deg); font-size: 20px; line-height: 1; filter: drop-shadow(0 1px 1px rgba(0,0,0,.6)); pointer-events: none; }
|
|
238
|
+
.rbot .lvl { font-family: var(--mono); font-size: 10px; color: #ffd66b; border: 1px solid #6b5a2a; border-radius: 10px; padding: 0 5px; flex: none; }
|
|
239
|
+
.rbot .h.gold, .farm .fbot.gold .nm { color: #ffd66b; }
|
|
240
|
+
.farm .fbot .hat { position: absolute; top: -15px; left: 50%; transform: translateX(-50%) rotate(-8deg); font-size: 21px; line-height: 1; z-index: 5; pointer-events: none; filter: drop-shadow(0 1px 1px rgba(0,0,0,.6)); }
|
|
241
|
+
.farm .fbot.gold img { box-shadow: 0 0 0 3px #ffd66b, 0 0 14px rgba(255,214,107,.6), 0 6px 10px rgba(0,0,0,.45); }
|
|
242
|
+
pre.packet { white-space: pre-wrap; font-family: var(--mono); font-size: 12.5px; line-height: 1.5; margin: 0; }
|
|
243
|
+
.btnlink { display: inline-flex; align-items: center; text-decoration: none; color: var(--dim); border: 1px solid transparent; border-radius: var(--radius); padding: 3px 9px; font-size: 12px; }
|
|
244
|
+
.btnlink:hover { background: var(--panel-2); color: var(--text); }
|
|
245
|
+
.scrub { display: flex; align-items: center; gap: 8px; }
|
|
246
|
+
.scrub input[type=range] { flex: 1; accent-color: var(--accent); }
|
|
247
|
+
/* harvest */
|
|
248
|
+
.harvest { position: fixed; inset: 0; z-index: 60; background: rgba(8,9,11,.55); display: grid; place-items: center; overflow: hidden; animation: rise .25s ease-out; }
|
|
249
|
+
.harvest .fall { position: absolute; top: -40px; font-size: 28px; animation: fall linear forwards; pointer-events: none; }
|
|
250
|
+
@keyframes fall { to { transform: translateY(110vh) rotate(540deg); opacity: .9 } }
|
|
251
|
+
.harvest .hcard { position: relative; background: var(--panel); border: 1px solid var(--line-2); border-radius: 16px; padding: 22px 26px; width: min(560px, 92vw); text-align: center; box-shadow: 0 30px 80px rgba(0,0,0,.55); animation: pop .45s cubic-bezier(.2,.8,.3,1.2); }
|
|
252
|
+
.harvest .hbig { font-size: 54px; line-height: 1; animation: bob 1.6s ease-in-out infinite; }
|
|
253
|
+
.harvest h2 { margin: 8px 0 14px; font-size: 18px; }
|
|
254
|
+
.hstats { display: flex; justify-content: center; gap: 18px; flex-wrap: wrap; }
|
|
255
|
+
.hstats div { display: flex; flex-direction: column; min-width: 70px; }
|
|
256
|
+
.hstats b { font-family: var(--mono); font-size: 20px; }
|
|
257
|
+
.hstats.small b { font-size: 16px; }
|
|
258
|
+
.hstats i { font-style: normal; font-size: 11px; color: var(--faint); }
|
|
259
|
+
.hstats .good b { color: #8fe0a0; }
|
|
260
|
+
.lvups { margin-top: 14px; display: flex; flex-direction: column; gap: 6px; }
|
|
261
|
+
.lvup { font-size: 13.5px; color: #ffd66b; animation: rise .5s ease-out both; }
|
|
262
|
+
.hacts { margin-top: 18px; display: flex; gap: 8px; justify-content: center; }
|
|
263
|
+
.hpanel { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 12px; margin-top: 18px; }
|
|
264
|
+
.hlist { display: flex; flex-direction: column; gap: 4px; margin-top: 10px; }
|
|
265
|
+
.hrow { display: flex; gap: 8px; align-items: center; font-size: 13px; flex-wrap: wrap; padding: 3px 0; border-bottom: 1px solid var(--line); }
|
|
266
|
+
.hrow .note { flex: 1 1 180px; }
|
|
267
|
+
.hrow b { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 220px; }
|
|
268
|
+
.hrow button { margin-left: auto; }
|
|
269
|
+
.lrow { display: grid; grid-template-columns: 28px 90px 1fr 100px auto; gap: 8px; align-items: center; padding: 4px 0; font-size: 13px; }
|
|
270
|
+
.lrow .lhat { font-size: 20px; }
|
|
271
|
+
.lbar { height: 6px; border-radius: 3px; background: var(--line); overflow: hidden; }
|
|
272
|
+
.lbar i { display: block; height: 100%; background: #ffd66b; }
|
|
273
|
+
@media (prefers-reduced-motion: reduce) { .harvest .hbig, .harvest .hcard, .lvup { animation: none } }
|
|
274
|
+
.page a, .tierbox a, .drawer a { color: var(--ok); }
|
|
275
|
+
.tiers { display: flex; flex-direction: column; gap: 10px; }
|
|
276
|
+
.tier { display: grid; grid-template-columns: 70px minmax(220px, 1fr) 150px minmax(160px, 1fr); gap: 12px; align-items: end; }
|
|
277
|
+
.tier .diff { align-self: center; justify-self: start; font-size: 12px; padding: 2px 10px; }
|
|
278
|
+
.tier .field { margin-bottom: 0; }
|
|
279
|
+
.tier .tierhelp { align-self: center; }
|
|
280
|
+
@media (max-width: 900px) { .tier { grid-template-columns: 70px 1fr; } .tier .tierhelp { grid-column: 1 / -1; } }
|
|
281
|
+
.switch { display: flex; align-items: center; gap: 8px; margin: 12px 0 4px; }
|
|
282
|
+
.switch input { width: 18px; height: 18px; accent-color: var(--accent); }
|
|
283
|
+
.tier .quota { grid-column: 2 / -1; font-size: 12.5px; }
|
|
284
|
+
.tier .quota summary { cursor: pointer; color: var(--dim); }
|
|
285
|
+
.tier .quota .qsum { font-family: var(--mono); font-size: 11.5px; color: var(--wait); }
|
|
286
|
+
.qgrid { display: grid; grid-template-columns: repeat(4, minmax(90px, 1fr)) 1.4fr 1.4fr 130px; gap: 8px; margin: 8px 0 4px; align-items: end; }
|
|
287
|
+
.qgrid .field { margin-bottom: 0; }
|
|
288
|
+
@media (max-width: 1100px) { .qgrid { grid-template-columns: repeat(4, 1fr); } }
|
|
289
|
+
.qmeter { display: inline-flex; align-items: center; gap: 6px; margin-left: 10px; font-family: var(--mono); font-size: 11px; color: var(--dim); }
|
|
290
|
+
.qmeter .bar { width: 60px; height: 5px; border-radius: 3px; background: var(--line); overflow: hidden; display: inline-block; }
|
|
291
|
+
.qmeter .bar i { display: block; height: 100%; background: var(--ok); }
|
|
292
|
+
.qmeter.over { color: var(--busy); } .qmeter.over .bar i { background: var(--busy); }
|
|
293
|
+
.tierbox { border: 1px solid var(--line); border-radius: 8px; padding: 10px 14px; margin: 4px 0 14px; background: var(--ink-2); }
|
|
294
|
+
.tierbox summary { cursor: pointer; }
|
|
295
|
+
ul.plain { margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; }
|
|
296
|
+
/* card completion events */
|
|
297
|
+
.cardev { margin: 12px 0 4px 56px; max-width: 900px; border: 1px solid color-mix(in srgb, var(--fg, var(--ok)) 45%, var(--line)); border-left: 3px solid var(--fg, var(--ok)); border-radius: 10px; background: var(--panel); padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; }
|
|
298
|
+
.cardev.sysev { --fg: #8fe0a0; }
|
|
299
|
+
.cardev .ceh { display: flex; gap: 10px; align-items: center; margin-left: -56px; }
|
|
300
|
+
.cardev .ceh .av { width: 44px; height: 44px; border-radius: 10px; flex: none; }
|
|
301
|
+
.cardev .ceh .cebig { width: 44px; text-align: center; font-size: 26px; flex: none; }
|
|
302
|
+
.cardev .cet { display: flex; flex-direction: column; min-width: 0; font-size: 12.5px; color: var(--dim); }
|
|
303
|
+
.cardev .cet .who { font-family: var(--mono); color: var(--fg, var(--text)); }
|
|
304
|
+
.cardev .cet .stage { font-family: var(--mono); font-size: 11px; color: var(--wait); border: 1px solid #4a3d7a; border-radius: 10px; padding: 0 7px; }
|
|
305
|
+
.cardev .cettl { font-size: 14px; color: var(--text); font-weight: 500; }
|
|
306
|
+
.cechips { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
307
|
+
.cechips > span { font-family: var(--mono); font-size: 11px; color: var(--dim); background: var(--ink-2); border: 1px solid var(--line); border-radius: 12px; padding: 1px 8px; }
|
|
308
|
+
.cechips .warnc { color: var(--wait); border-color: #4a3d7a; }
|
|
309
|
+
.add { color: #8fe0a0; } .del { color: #ffb0a6; }
|
|
310
|
+
.cesum { font-size: 13px; white-space: pre-wrap; color: var(--dim); }
|
|
311
|
+
.cesum.long { max-height: 6.5em; overflow: hidden; -webkit-mask-image: linear-gradient(#000 60%, transparent); mask-image: linear-gradient(#000 60%, transparent); }
|
|
312
|
+
.shots { display: flex; gap: 8px; flex-wrap: wrap; }
|
|
313
|
+
.shots img { height: 96px; max-width: 180px; object-fit: cover; border-radius: 6px; border: 1px solid var(--line-2); cursor: zoom-in; background: var(--ink); }
|
|
314
|
+
.cefiles summary { cursor: pointer; font-size: 12.5px; color: var(--dim); }
|
|
315
|
+
.difflist { display: flex; flex-direction: column; gap: 4px; margin-top: 6px; }
|
|
316
|
+
.dfile { border: 1px solid var(--line); border-radius: 6px; overflow: hidden; }
|
|
317
|
+
.dfile summary { cursor: pointer; padding: 4px 8px; background: var(--ink-2); font-size: 12px; display: flex; gap: 8px; align-items: baseline; }
|
|
318
|
+
.fbadge { font-family: var(--mono); font-size: 10px; border: 1px solid var(--line-2); border-radius: 8px; padding: 0 6px; color: #8fe0a0; }
|
|
319
|
+
.fbadge.del { color: #ffb0a6; } .fbadge.warn { color: var(--busy); border-color: #6b5a2a; }
|
|
320
|
+
pre.diff { margin: 0; padding: 8px 10px; font-family: var(--mono); font-size: 11.5px; line-height: 1.45; max-height: 50vh; overflow: auto; background: var(--ink); white-space: pre; }
|
|
321
|
+
pre.diff .a { color: #8fe0a0; background: rgba(80,160,100,.12); display: inline-block; min-width: 100%; }
|
|
322
|
+
pre.diff .r { color: #ffb0a6; background: rgba(200,80,70,.12); display: inline-block; min-width: 100%; }
|
|
323
|
+
pre.diff .at { color: var(--ok); } pre.diff .h { color: var(--faint); }
|
|
324
|
+
.lightbox { position: fixed; inset: 0; z-index: 70; background: rgba(0,0,0,.8); display: grid; place-items: center; cursor: zoom-out; animation: rise .15s ease-out; }
|
|
325
|
+
.lightbox img { max-width: 94vw; max-height: 88vh; border-radius: 8px; box-shadow: 0 20px 60px rgba(0,0,0,.6); }
|
|
326
|
+
.lightbox button { position: absolute; top: 16px; right: 16px; }
|
|
327
|
+
.benchgrid { display: grid; grid-template-columns: minmax(320px, 1fr) minmax(380px, 1.2fr); gap: 16px; margin-top: 12px; align-items: start; }
|
|
328
|
+
@media (max-width: 1100px) { .benchgrid { grid-template-columns: 1fr; } }
|
|
329
|
+
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; min-width: 0; }
|
|
330
|
+
.panel h3 { margin: 0 0 10px; font-size: 13px; }
|
|
331
|
+
.benchout { margin-top: 12px; border: 1px solid var(--line-2); border-radius: 8px; overflow: hidden; animation: rise .2s ease-out; }
|
|
332
|
+
.benchout.ok { border-color: color-mix(in srgb, var(--ok) 60%, var(--line)); }
|
|
333
|
+
.benchout.bad { border-color: var(--error); }
|
|
334
|
+
.benchout .bh { display: flex; gap: 10px; align-items: baseline; padding: 6px 10px; background: var(--ink-2); font-size: 12px; }
|
|
335
|
+
.benchout.bad .bh b { color: var(--error); }
|
|
336
|
+
.benchout pre { margin: 0; padding: 10px; font-family: var(--mono); font-size: 12px; white-space: pre-wrap; word-break: break-word; max-height: 45vh; overflow: auto; }
|
|
337
|
+
.benchout pre.log { color: var(--dim); border-top: 1px solid var(--line); }
|
|
338
|
+
.tabs button[aria-pressed="true"] { background: var(--panel-2); border-color: var(--line-2); color: var(--text); }
|
|
339
|
+
|
|
340
|
+
/* ---------- drawer + dialogs ---------- */
|
|
341
|
+
.scrim { position: fixed; inset: 0; background: rgba(8,9,11,.55); z-index: 30; }
|
|
342
|
+
.drawer { position: fixed; top: 0; right: 0; bottom: 0; width: min(760px, 96vw); z-index: 31; background: var(--panel); border-left: 1px solid var(--line-2); display: flex; flex-direction: column; box-shadow: -20px 0 60px rgba(0,0,0,.45); animation: slide .16s ease-out; }
|
|
343
|
+
@keyframes slide { from { transform: translateX(24px); opacity: .6 } }
|
|
344
|
+
@media (prefers-reduced-motion: reduce) { .drawer { animation: none } }
|
|
345
|
+
.drawer .dh { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-bottom: 1px solid var(--line); }
|
|
346
|
+
.drawer .dh img { width: 48px; height: 48px; border-radius: 10px; }
|
|
347
|
+
.drawer .dh .h { font-family: var(--mono); font-size: 15px; }
|
|
348
|
+
.drawer .dh .s { font-size: 12px; color: var(--dim); }
|
|
349
|
+
.drawer .dh .acts { margin-left: auto; display: flex; gap: 6px; }
|
|
350
|
+
.drawer .kv { display: flex; gap: 14px; flex-wrap: wrap; padding: 8px 16px; border-bottom: 1px solid var(--line); font-family: var(--mono); font-size: 11.5px; color: var(--dim); }
|
|
351
|
+
.drawer .kv b { color: var(--text); font-weight: 500; }
|
|
352
|
+
.drawer .body { flex: 1; overflow-y: auto; padding: 16px; }
|
|
353
|
+
.tune { padding: 10px 16px; border-bottom: 1px solid var(--line); background: var(--ink-2); animation: rise .2s ease-out; }
|
|
354
|
+
.tune .tuneh { display: flex; align-items: baseline; gap: 10px; margin-bottom: 8px; }
|
|
355
|
+
.tune .askf { display: flex; align-items: center; gap: 8px; }
|
|
356
|
+
.tune .askf .note { margin-right: auto; }
|
|
357
|
+
.rbot .ovr { color: var(--wait); }
|
|
358
|
+
.transcript { flex: 1; overflow-y: auto; padding: 14px 16px; display: flex; flex-direction: column; gap: 12px; }
|
|
359
|
+
.turn { display: flex; gap: 10px; }
|
|
360
|
+
.turn img, .turn .avdot { width: 40px; height: 40px; border-radius: 9px; flex: none; }
|
|
361
|
+
.turn .avdot { background: #1c4461; border: 1px solid #3a7aa6; }
|
|
362
|
+
.turn .c { min-width: 0; flex: 1; }
|
|
363
|
+
.turn .m { font-family: var(--mono); font-size: 11px; color: var(--faint); margin-bottom: 2px; }
|
|
364
|
+
.turn .b { font-size: 13.5px; white-space: pre-wrap; word-break: break-word; }
|
|
365
|
+
.turn .b.long { max-height: 8.5em; overflow: hidden; -webkit-mask-image: linear-gradient(#000 70%, transparent); mask-image: linear-gradient(#000 70%, transparent); }
|
|
366
|
+
.turn details > summary { cursor: pointer; list-style: none; }
|
|
367
|
+
.turn details > summary::-webkit-details-marker { display: none; }
|
|
368
|
+
.turn .lbl { display: block; font-family: var(--mono); font-size: 11px; color: var(--faint); margin-top: 3px; }
|
|
369
|
+
.turn details.more > summary .less, .turn details.more[open] > summary .b, .turn details.more[open] > summary .lbl:not(.less) { display: none; }
|
|
370
|
+
.turn details.more[open] > summary .less { display: block; }
|
|
371
|
+
.turn details.think > div { font-size: 12.5px; color: var(--dim); font-style: italic; white-space: pre-wrap; border-left: 2px solid var(--line-2); padding-left: 8px; margin-top: 3px; }
|
|
372
|
+
.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; }
|
|
373
|
+
.turn .call .nm { color: var(--busy); flex: none; }
|
|
374
|
+
.turn .call .sm { color: var(--dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; flex: 1; }
|
|
375
|
+
.turn .call .st { color: var(--faint); flex: none; }
|
|
376
|
+
.turn .call.s-running .st, .turn .call.s-pending .st { color: var(--busy); }
|
|
377
|
+
.turn .call.s-error { border-color: var(--error); } .turn .call.s-error .st { color: var(--error); }
|
|
378
|
+
.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; }
|
|
379
|
+
.working { font-family: var(--mono); font-size: 12px; color: var(--busy); padding-left: 50px; }
|
|
380
|
+
.working i { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--busy); margin-right: 7px; animation: pulse 1.2s ease-in-out infinite; }
|
|
381
|
+
@keyframes pulse { 50% { opacity: .25 } }
|
|
382
|
+
@media (prefers-reduced-motion: reduce) { .working i { animation: none } }
|
|
383
|
+
.cardbar { border-bottom: 1px solid var(--line); background: var(--ink-2); padding: 8px 16px; display: flex; flex-direction: column; gap: 8px; max-height: 50vh; overflow-y: auto; }
|
|
384
|
+
.cardbar .cb1 { display: flex; align-items: center; gap: 10px; min-width: 0; flex-wrap: wrap; }
|
|
385
|
+
.cardbar .stage { font-family: var(--mono); font-size: 11px; color: var(--wait); }
|
|
386
|
+
.cardbar .ct { font-weight: 600; font-size: 13.5px; min-width: 0; }
|
|
387
|
+
.cardbar .acts { margin-left: auto; display: flex; gap: 6px; align-items: center; }
|
|
388
|
+
.cardbar select { font-size: 12px; padding: 3px 6px; }
|
|
389
|
+
.cardbar .chip.st-doing { color: var(--busy); border-color: var(--busy); }
|
|
390
|
+
.cardbar .chip.st-needs { color: var(--wait); border-color: var(--wait); }
|
|
391
|
+
.cardbar .chip.st-done { color: var(--ok); border-color: #2c6a8c; }
|
|
392
|
+
.cardbar .cb-details { border-top: 1px dashed var(--line); padding-top: 10px; }
|
|
393
|
+
.x-asks { display: flex; flex-direction: column; gap: 8px; padding: 0 16px; }
|
|
394
|
+
.x-asks:not(:empty) { padding: 10px 16px; border-top: 1px solid var(--line); max-height: 45vh; overflow-y: auto; }
|
|
395
|
+
.perm-ask { border-color: var(--busy); }
|
|
396
|
+
.perm-ask .askh b { color: var(--busy); }
|
|
397
|
+
.perm-detail { margin: 0; font-family: var(--mono); font-size: 12px; background: var(--ink); border: 1px solid var(--line); border-radius: 4px; padding: 6px 8px; white-space: pre-wrap; word-break: break-all; max-height: 10em; overflow: auto; }
|
|
398
|
+
.sec { margin-bottom: 18px; }
|
|
399
|
+
.sec h3 { font-size: 11px; text-transform: uppercase; letter-spacing: .07em; color: var(--faint); margin: 0 0 6px; font-weight: 600; }
|
|
400
|
+
.sec .pre { white-space: pre-wrap; font-size: 13px; }
|
|
401
|
+
.sec ul { margin: 0; padding-left: 18px; }
|
|
402
|
+
dialog { background: var(--panel); color: var(--text); border: 1px solid var(--line-2); border-radius: 10px; padding: 0; width: min(620px, 94vw); box-shadow: 0 30px 80px rgba(0,0,0,.55); }
|
|
403
|
+
dialog::backdrop { background: rgba(4,8,14,.6); }
|
|
404
|
+
dialog .dhd { padding: 14px 18px 0; display: flex; align-items: center; }
|
|
405
|
+
dialog .dhd h2 { font-size: 16px; margin: 0; }
|
|
406
|
+
dialog .dbd { padding: 14px 18px; }
|
|
407
|
+
dialog .dft { padding: 12px 18px 16px; display: flex; gap: 8px; justify-content: flex-end; align-items: center; }
|
|
408
|
+
dialog .dft .err { margin-right: auto; }
|
|
409
|
+
.fs { border: 1px solid var(--line); border-radius: 6px; height: 320px; overflow-y: auto; background: var(--ink-2); }
|
|
410
|
+
.fs button { display: flex; width: 100%; gap: 8px; align-items: center; border: 0; border-radius: 0; background: transparent; text-align: left; padding: 6px 10px; font-family: var(--mono); font-size: 12.5px; }
|
|
411
|
+
.fs button:hover { background: var(--panel-2); }
|
|
412
|
+
.fs .git { margin-left: auto; font-size: 10.5px; color: var(--ok); }
|
|
413
|
+
.pathbar { display: flex; gap: 6px; margin-bottom: 8px; }
|
|
414
|
+
.pathbar input { flex: 1; font-family: var(--mono); font-size: 12.5px; }
|
|
415
|
+
.svcs { display: flex; flex-direction: column; gap: 4px; max-height: 150px; overflow-y: auto; border: 1px solid var(--line); border-radius: 6px; padding: 6px 10px; background: var(--ink-2); }
|
|
416
|
+
.svcs label { display: flex; gap: 8px; align-items: center; font-family: var(--mono); font-size: 12px; }
|
|
417
|
+
.svcs label [data-unlink] { margin-left: auto; padding: 0 6px; }
|
|
418
|
+
.addrepo { display: flex; gap: 6px; margin-top: 6px; }
|
|
419
|
+
.aptab { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
420
|
+
.aptab th, .aptab td { text-align: left; vertical-align: top; padding: 7px 6px; border-top: 1px solid var(--line); }
|
|
421
|
+
.aptab summary { cursor: pointer; }
|
|
422
|
+
.apb { font-size: 12px; white-space: nowrap; }
|
|
423
|
+
.apb.ok { color: var(--ok); } .apb.busy { color: var(--busy); } .apb.err { color: var(--error); } .apb.dim { color: var(--faint); }
|
|
424
|
+
.warnbox { border: 1px solid var(--busy); color: var(--busy); border-radius: 6px; padding: 6px 10px; margin-bottom: 8px; font-size: 13px; }
|
|
425
|
+
.addrepo input { flex: 1; }
|
|
426
|
+
.stageprev { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 6px; }
|
|
427
|
+
.empty { color: var(--faint); font-size: 13px; padding: 10px 2px; }
|
|
428
|
+
@media (max-width: 760px) { .app { grid-template-columns: 1fr; } aside.side { display: none; } .board { grid-template-columns: repeat(4, 80vw); } }
|
|
429
|
+
|
|
430
|
+
/* ---------- workstream: bots down the left, like a DM list ---------- */
|
|
431
|
+
.wlayout { flex: 1; min-height: 0; display: grid; grid-template-columns: 272px 1fr; }
|
|
432
|
+
.wmain { min-width: 0; min-height: 0; display: flex; flex-direction: column; }
|
|
433
|
+
.rail { border-right: 1px solid var(--line); background: var(--ink-2); display: flex; flex-direction: column; min-height: 0; }
|
|
434
|
+
.rail .rh { display: flex; align-items: center; gap: 8px; padding: 12px 14px 8px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; color: var(--faint); }
|
|
435
|
+
.rail .rh button { margin-left: auto; }
|
|
436
|
+
.rail .list { flex: 1; overflow-y: auto; padding: 0 8px 12px; display: flex; flex-direction: column; gap: 2px; }
|
|
437
|
+
.rail .grp { font-size: 10.5px; color: var(--faint); font-family: var(--mono); margin: 10px 8px 4px; display: flex; align-items: center; gap: 6px; }
|
|
438
|
+
.rail .grp::after { content: ""; flex: 1; height: 1px; background: var(--line); }
|
|
439
|
+
.rbot { display: grid; grid-template-columns: 44px 1fr; gap: 10px; align-items: center; padding: 7px 8px; border-radius: 10px; cursor: pointer; border: 1px solid transparent; position: relative; --fg: var(--line-2); transition: background .15s, border-color .15s, transform .15s; }
|
|
440
|
+
.rbot:hover { background: var(--panel); border-color: var(--line); }
|
|
441
|
+
.rbot .ava { position: relative; width: 44px; height: 44px; }
|
|
442
|
+
.rbot .ava img { width: 44px; height: 44px; border-radius: 50%; display: block; box-shadow: 0 0 0 2px var(--fg); }
|
|
443
|
+
.rbot .ava .pip { position: absolute; right: -1px; bottom: -1px; width: 13px; height: 13px; border-radius: 50%; border: 2.5px solid var(--ink-2); background: var(--idle); }
|
|
444
|
+
.rbot.busy .ava .pip { background: var(--busy); } .rbot.waiting .ava .pip { background: var(--wait); } .rbot.error .ava .pip { background: var(--error); }
|
|
445
|
+
.rbot.busy .ava::before { content: ""; position: absolute; inset: -5px; border-radius: 50%; background: conic-gradient(var(--busy) 0 25%, transparent 25% 50%, var(--busy) 50% 75%, transparent 75%); -webkit-mask: radial-gradient(circle, transparent 25px, #000 26px); mask: radial-gradient(circle, transparent 25px, #000 26px); animation: spin 2.4s linear infinite; }
|
|
446
|
+
.rbot.waiting .ava::before { content: ""; position: absolute; inset: -4px; border-radius: 50%; border: 2px solid var(--wait); animation: pulse 1.4s ease-in-out infinite; }
|
|
447
|
+
@keyframes spin { to { transform: rotate(360deg) } }
|
|
448
|
+
.rbot .who { min-width: 0; display: flex; flex-direction: column; line-height: 1.25; }
|
|
449
|
+
.rbot .top { display: flex; align-items: baseline; gap: 6px; min-width: 0; }
|
|
450
|
+
.rbot .h { font-family: var(--mono); font-size: 13px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
451
|
+
.rbot.busy .h { color: var(--busy); }
|
|
452
|
+
.rbot .kind { font-size: 10.5px; color: var(--fg); border: 1px solid color-mix(in srgb, var(--fg) 55%, transparent); border-radius: 10px; padding: 0 6px; white-space: nowrap; flex: none; }
|
|
453
|
+
.rbot .d { font-size: 12px; color: var(--dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
454
|
+
.rbot.error .d { color: var(--error); }
|
|
455
|
+
.rbot.waiting .d { color: var(--wait); }
|
|
456
|
+
.rbot .stats { display: flex; gap: 8px; font-family: var(--mono); font-size: 10.5px; color: var(--faint); margin-top: 1px; flex-wrap: wrap; }
|
|
457
|
+
.rbot .stats b { color: var(--dim); font-weight: 500; }
|
|
458
|
+
.rbot .stats .bad { color: var(--error); }
|
|
459
|
+
.rbot .model { font-family: var(--mono); font-size: 10.5px; color: var(--faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
460
|
+
.rbot .unread { position: absolute; right: 8px; top: 8px; min-width: 8px; height: 8px; border-radius: 50%; background: var(--wait); }
|
|
461
|
+
.viewtabs { display: flex; gap: 2px; background: var(--ink); border: 1px solid var(--line); border-radius: 8px; padding: 2px; }
|
|
462
|
+
.viewtabs button { border: 0; background: transparent; padding: 3px 12px; font-size: 12px; color: var(--dim); border-radius: 6px; }
|
|
463
|
+
.viewtabs button[aria-pressed="true"] { background: var(--panel-2); color: var(--text); box-shadow: 0 1px 0 rgba(255,255,255,.06) inset; }
|
|
464
|
+
|
|
465
|
+
/* budgets */
|
|
466
|
+
.budget { display: inline-flex; align-items: center; gap: 10px; font-family: var(--mono); font-size: 11px; color: var(--dim); border: 1px solid var(--line-2); border-radius: 20px; padding: 2px 4px 2px 10px; }
|
|
467
|
+
.budget .m { display: inline-flex; align-items: center; gap: 5px; }
|
|
468
|
+
.budget .bar { width: 44px; height: 5px; border-radius: 3px; background: var(--line); overflow: hidden; }
|
|
469
|
+
.budget .bar i { display: block; height: 100%; background: var(--ok); border-radius: 3px; transition: width .6s ease; }
|
|
470
|
+
.budget .bar i.warn { background: var(--busy); } .budget .bar i.over { background: var(--error); }
|
|
471
|
+
.budget button { padding: 1px 8px; font-size: 11px; border-radius: 12px; }
|
|
472
|
+
.paused { margin: 0 20px; padding: 9px 12px; border: 1px solid var(--busy); border-radius: 8px; background: color-mix(in srgb, var(--busy) 12%, var(--panel)); display: flex; align-items: center; gap: 10px; font-size: 13px; animation: rise .25s ease-out; }
|
|
473
|
+
.paused b { color: var(--busy); }
|
|
474
|
+
.paused .acts { margin-left: auto; display: flex; gap: 6px; }
|
|
475
|
+
.card .usage { height: 4px; border-radius: 2px; background: var(--line); overflow: hidden; }
|
|
476
|
+
.card .usage i { display: block; height: 100%; background: var(--busy); transition: width .6s ease; }
|
|
477
|
+
.card .usage i.over { background: var(--error); }
|
|
478
|
+
.card .pieces { display: flex; align-items: center; gap: 6px; font-family: var(--mono); font-size: 11px; color: var(--dim); }
|
|
479
|
+
.card .pieces .track { flex: 1; height: 6px; border-radius: 3px; background: var(--line); overflow: hidden; display: flex; }
|
|
480
|
+
.card .pieces .track i { height: 100%; background: var(--ok); transition: width .6s ease; }
|
|
481
|
+
.card .pieces .track i.run { background: var(--busy); }
|
|
482
|
+
.card .part { font-family: var(--mono); font-size: 10.5px; color: var(--faint); }
|
|
483
|
+
.card.group { border-style: dashed; }
|
|
484
|
+
.card .avs { display: flex; margin-left: auto; }
|
|
485
|
+
.card .avs img { width: 22px; height: 22px; border-radius: 50%; margin-left: -6px; box-shadow: 0 0 0 2px var(--panel); }
|
|
486
|
+
.ask.budget-ask { border-color: var(--busy); }
|
|
487
|
+
.ask.budget-ask .askh b { color: var(--busy); }
|
|
488
|
+
.limits { display: flex; gap: 8px; }
|
|
489
|
+
.limits input { width: 100%; font-family: var(--mono); font-size: 12.5px; }
|
|
490
|
+
|
|
491
|
+
/* motion: cards glide between columns, new things arrive, nothing jumps */
|
|
492
|
+
@keyframes rise { from { opacity: 0; transform: translateY(6px) } }
|
|
493
|
+
@keyframes pop { 0% { opacity: 0; transform: scale(.92) } 70% { transform: scale(1.02) } }
|
|
494
|
+
.card.enter { animation: pop .35s cubic-bezier(.2,.8,.3,1.2); }
|
|
495
|
+
.msg.enter { animation: rise .3s ease-out; }
|
|
496
|
+
.rbot.enter { animation: rise .35s ease-out; }
|
|
497
|
+
.card.flash { box-shadow: 0 0 0 2px var(--ok); transition: box-shadow .8s; }
|
|
498
|
+
.step { transition: color .3s, border-color .3s, background .3s; }
|
|
499
|
+
.step.active { background: color-mix(in srgb, var(--busy) 12%, transparent); }
|
|
500
|
+
|
|
501
|
+
/* ---------- the farm: the board as a place ---------- */
|
|
502
|
+
.farm { position: relative; overflow: hidden; min-height: 0; border-radius: 0; user-select: none;
|
|
503
|
+
background: linear-gradient(#26384a 0%, #2d4658 38%, #32503b 38.2%, #2c4633 100%); }
|
|
504
|
+
.farm .sun { position: absolute; top: 14px; right: 60px; width: 42px; height: 42px; border-radius: 50%; background: radial-gradient(#ffe7a3, #ffc75a 60%, transparent 62%); box-shadow: 0 0 40px 10px rgba(255,200,90,.25); animation: bob 6s ease-in-out infinite; }
|
|
505
|
+
.farm .cloud { position: absolute; top: 22px; width: 90px; height: 22px; border-radius: 20px; background: rgba(255,255,255,.14); animation: drift 60s linear infinite; }
|
|
506
|
+
.farm .cloud::before { content: ""; position: absolute; left: 18px; top: -12px; width: 40px; height: 28px; border-radius: 50%; background: inherit; }
|
|
507
|
+
@keyframes drift { from { transform: translateX(-120px) } to { transform: translateX(110vw) } }
|
|
508
|
+
@keyframes bob { 50% { transform: translateY(-4px) } }
|
|
509
|
+
.farm .zone { position: absolute; border-radius: 12px; display: flex; flex-direction: column; }
|
|
510
|
+
.farm .zone .zl { position: absolute; left: 10px; top: 8px; font-family: var(--mono); font-size: 11px; color: rgba(255,255,255,.85); text-shadow: 0 1px 2px rgba(0,0,0,.6); display: flex; gap: 6px; align-items: center; z-index: 1; }
|
|
511
|
+
.farm .zone .zl i { font-style: normal; font-size: 16px; }
|
|
512
|
+
.farm .shed { background: rgba(120,84,52,.35); border: 2px solid rgba(160,112,70,.6); }
|
|
513
|
+
.farm .field { background: repeating-linear-gradient(90deg, rgba(92,64,38,.55) 0 18px, rgba(80,55,32,.55) 18px 22px); border: 2px solid rgba(140,100,60,.5); }
|
|
514
|
+
.farm .barn { background: rgba(150,52,52,.32); border: 2px solid rgba(200,90,80,.6); }
|
|
515
|
+
.farm .silo { background: rgba(170,170,160,.18); border: 2px solid rgba(210,210,200,.45); }
|
|
516
|
+
.farm .house { background: rgba(60,80,110,.35); border: 2px dashed rgba(150,180,220,.45); }
|
|
517
|
+
.farm .crop { position: absolute; width: 58px; text-align: center; transition: left 1.1s cubic-bezier(.4,.1,.2,1), top 1.1s cubic-bezier(.4,.1,.2,1), opacity .4s; cursor: pointer; z-index: 2; }
|
|
518
|
+
.farm .crop .g { font-size: 26px; line-height: 30px; display: block; filter: drop-shadow(0 2px 2px rgba(0,0,0,.4)); transition: transform .3s; }
|
|
519
|
+
.farm .crop:hover .g { transform: scale(1.15); }
|
|
520
|
+
.farm .crop .t { display: block; font-size: 9.5px; color: #fff; text-shadow: 0 1px 2px #000; line-height: 1.15; max-height: 2.3em; overflow: hidden; }
|
|
521
|
+
.farm .crop .grow { display: block; margin: 2px auto 0; width: 40px; height: 4px; border-radius: 2px; background: rgba(0,0,0,.35); overflow: hidden; }
|
|
522
|
+
.farm .crop .grow i { display: block; height: 100%; background: #9be15d; transition: width .8s; }
|
|
523
|
+
.farm .crop.active .g { animation: sway 2.4s ease-in-out infinite; transform-origin: bottom center; }
|
|
524
|
+
.farm .crop.needs .g::after { content: "!"; }
|
|
525
|
+
@keyframes sway { 50% { transform: rotate(6deg) } }
|
|
526
|
+
.farm .fbot { position: absolute; width: 42px; height: 42px; z-index: 4; transition: left 1.6s cubic-bezier(.45,.05,.25,1), top 1.6s cubic-bezier(.45,.05,.25,1); cursor: pointer; }
|
|
527
|
+
.farm .fbot img { width: 42px; height: 42px; border-radius: 50%; box-shadow: 0 0 0 3px var(--fg, #fff), 0 6px 10px rgba(0,0,0,.45); display: block; background: var(--panel); }
|
|
528
|
+
.farm .fbot .nm { position: absolute; top: 45px; left: 50%; transform: translateX(-50%); font-family: var(--mono); font-size: 10px; color: #fff; white-space: nowrap; text-shadow: 0 1px 2px #000; background: rgba(0,0,0,.55); padding: 0 5px; border-radius: 6px; opacity: 0; transition: opacity .15s; pointer-events: none; }
|
|
529
|
+
.farm .fbot:hover { z-index: 6; } .farm .fbot:hover .nm { opacity: 1; }
|
|
530
|
+
.farm .fbot.busy { animation: hop .7s ease-in-out infinite; }
|
|
531
|
+
.farm .fbot.walking img { animation: waddle .35s ease-in-out infinite; }
|
|
532
|
+
@keyframes hop { 50% { margin-top: -5px } }
|
|
533
|
+
@keyframes waddle { 25% { transform: rotate(-7deg) } 75% { transform: rotate(7deg) } }
|
|
534
|
+
.farm .fbot .say { position: absolute; top: -8px; left: 36px; background: #fff; color: #222; font-family: var(--mono); font-size: 9.5px; border-radius: 8px; padding: 1px 6px; white-space: nowrap; max-width: 110px; overflow: hidden; text-overflow: ellipsis; box-shadow: 0 2px 6px rgba(0,0,0,.35); }
|
|
535
|
+
.farm .fbot .say::after { content: ""; position: absolute; left: -3px; bottom: 1px; border: 4px solid transparent; border-right-color: #fff; border-left: 0; }
|
|
536
|
+
.farm .fbot.waiting .say { background: var(--wait); color: #1a1030; }
|
|
537
|
+
.farm .fbot.waiting .say::after { border-right-color: var(--wait); }
|
|
538
|
+
.farm .spark { position: absolute; width: 5px; height: 5px; border-radius: 50%; background: #ffd66b; box-shadow: 0 0 6px #ffd66b; animation: spark 1.4s ease-out forwards; pointer-events: none; z-index: 3; }
|
|
539
|
+
@keyframes spark { to { transform: translate(var(--dx), -34px); opacity: 0 } }
|
|
540
|
+
.farm .legend { position: absolute; left: 12px; bottom: 8px; font-family: var(--mono); font-size: 10.5px; color: rgba(255,255,255,.8); text-shadow: 0 1px 2px #000; }
|
|
541
|
+
@media (prefers-reduced-motion: reduce) {
|
|
542
|
+
.rbot.busy .ava::before, .farm .fbot.busy, .farm .fbot.walking img, .farm .crop.active .g, .farm .sun, .farm .cloud, .card.enter, .msg.enter, .rbot.enter { animation: none }
|
|
543
|
+
.farm .fbot, .farm .crop { transition: none }
|
|
544
|
+
}
|
|
545
|
+
.matrix{width:100%;border-collapse:collapse;font-size:13px}.matrix th,.matrix td{border-top:1px solid var(--line);padding:8px;text-align:left;vertical-align:top}.matrix td.own{background:color-mix(in srgb,var(--accent) 10%,transparent)}.matrix .chip{margin-top:4px}.matrix .chip.own{border-color:var(--accent);color:var(--accent)}.matrix .chip.dim{opacity:.6}
|
|
546
|
+
.tags .round{font-family:var(--mono);font-size:11px;color:var(--wait);border:1px solid var(--wait);border-radius:10px;padding:0 6px}.tags .round.chk{color:var(--busy);border-color:var(--busy)}
|
|
547
|
+
</style>
|
|
548
|
+
</head>
|
|
549
|
+
<body>
|
|
550
|
+
<div class="app">
|
|
551
|
+
<aside class="side">
|
|
552
|
+
<div class="brand"><b>BotFarm</b><span class="conn" id="conn">connecting…</span></div>
|
|
553
|
+
<button class="wspick" id="wspick" aria-expanded="false"><span class="n" id="ws-name">No folder</span><span class="p" id="ws-path"></span></button>
|
|
554
|
+
<div class="wsmenu" id="wsmenu" hidden></div>
|
|
555
|
+
<nav class="nav">
|
|
556
|
+
<h4>Workstreams <button class="ghost" id="new-stream" title="Start a workstream">+</button></h4>
|
|
557
|
+
<div id="nav-streams"></div>
|
|
558
|
+
<h4>Pipelines <button class="ghost" id="new-pipeline" title="New pipeline">+</button></h4>
|
|
559
|
+
<div id="nav-pipelines"></div>
|
|
560
|
+
<h4>Agents <button class="ghost" id="new-agent" title="New agent">+</button></h4>
|
|
561
|
+
<div id="nav-agents"></div>
|
|
562
|
+
<h4>Tools <button class="ghost" id="new-tool" title="New tool in botfarm/mcps/">+</button></h4>
|
|
563
|
+
<div id="nav-tools"></div>
|
|
564
|
+
<button class="item" id="nav-routing" data-k="routing"><span class="hash">⇅</span><span class="t">Model routing</span><span class="note mono" id="nav-routing-state"></span></button>
|
|
565
|
+
</nav>
|
|
566
|
+
<div class="foot">
|
|
567
|
+
<div><b id="f-busy">0</b><i>working</i></div>
|
|
568
|
+
<div><b id="f-tpm">0</b><i>tokens/min</i></div>
|
|
569
|
+
<div><b id="f-tok">0</b><i>tokens total</i></div>
|
|
570
|
+
<button class="spend" id="f-spend" title="Prices"><b id="f-cost">$0</b><i id="f-cost-l">spend</i></button>
|
|
571
|
+
<a href="/classic">Classic view — every session, adopt, rooms</a>
|
|
572
|
+
</div>
|
|
573
|
+
</aside>
|
|
574
|
+
<main id="main"></main>
|
|
575
|
+
</div>
|
|
576
|
+
|
|
577
|
+
<dialog id="d-folder">
|
|
578
|
+
<div class="dhd"><h2>Open a folder</h2></div>
|
|
579
|
+
<div class="dbd">
|
|
580
|
+
<div class="pathbar"><button class="sm" id="fs-up" title="Up one folder">↑</button><input id="fs-path" spellcheck="false" /><button class="sm" id="fs-go">Go</button></div>
|
|
581
|
+
<div class="fs" id="fs-list"></div>
|
|
582
|
+
<div class="note" id="fs-note" style="margin-top:8px">A workspace is a folder with its opencode config. BotFarm keeps its agents and pipelines in a <span class="mono">botfarm/</span> folder at its root, one file each (<span class="mono"><id>.agent.botfarm.yml</span>, <span class="mono"><id>.pipeline.botfarm.yml</span>), and its own tools in <span class="mono">botfarm/mcps/</span>. They are created if missing.</div>
|
|
583
|
+
</div>
|
|
584
|
+
<div class="dft"><span class="err" id="fs-err"></span><button id="fs-cancel">Cancel</button><button class="primary" id="fs-use">Use this folder</button></div>
|
|
585
|
+
</dialog>
|
|
586
|
+
|
|
587
|
+
<dialog id="d-stream">
|
|
588
|
+
<div class="dhd"><h2>Start a workstream</h2></div>
|
|
589
|
+
<div class="dbd">
|
|
590
|
+
<div class="field"><label for="ns-pipeline">Pipeline</label><select id="ns-pipeline"></select><div class="stageprev" id="ns-stages"></div></div>
|
|
591
|
+
<div class="field"><label for="ns-story">Story, defect or task</label>
|
|
592
|
+
<textarea id="ns-story" rows="7" placeholder="Paste the story with its acceptance criteria. Every bot gets all of it."></textarea></div>
|
|
593
|
+
<div class="row">
|
|
594
|
+
<div class="field"><label for="ns-title">Name</label><input id="ns-title" placeholder="DE238 no cc cancel after docs" /></div>
|
|
595
|
+
<div class="field"><label for="ns-branch">Branch (its own worktree)</label><input id="ns-branch" class="mono" placeholder="botfarm/de238-…" spellcheck="false" /></div>
|
|
596
|
+
</div>
|
|
597
|
+
<div class="field" id="ns-svc-field"><label>Repos to include in the worktree <span class="note">(each gets its own worktree on the same branch)</span></label><div class="svcs" id="ns-svcs"></div>
|
|
598
|
+
<div class="addrepo"><input id="ns-add-path" class="mono" placeholder="~/workspace/spt — a repo from anywhere" spellcheck="false" /><input id="ns-add-as" class="mono" placeholder="as (folder name)" spellcheck="false" style="max-width:150px" /><button class="sm" id="ns-add">Add repo</button></div></div>
|
|
599
|
+
<div class="field" id="ns-par-field" hidden><label>Bots working at once on split stages</label><div class="limits" id="ns-par"></div>
|
|
600
|
+
<div class="hint">A split stage turns the list the stage before it hands over into one card per item. This many bots of that kind take them at the same time, in the same worktree; the next stage waits for all of them.</div></div>
|
|
601
|
+
<div class="field"><label>Limits for the whole workstream <span class="note">(optional — it stops and asks you when it reaches one)</span></label><div id="ns-limits"></div></div>
|
|
602
|
+
</div>
|
|
603
|
+
<div class="dft"><span class="err" id="ns-err"></span><button id="ns-cancel">Cancel</button><button class="primary" id="ns-go">Start</button></div>
|
|
604
|
+
</dialog>
|
|
605
|
+
|
|
606
|
+
<dialog id="d-repos">
|
|
607
|
+
<div class="dhd"><h2>Add a repo to this workstream</h2></div>
|
|
608
|
+
<div class="dbd">
|
|
609
|
+
<p class="note" style="margin-top:0">It gets its own worktree on this workstream's branch, inside the workstream's worktree, and the bots are told where it is. A repo from outside the workspace is mounted under its folder name.</p>
|
|
610
|
+
<div class="field" id="ar-known-field"><label>From this workspace</label><div class="svcs" id="ar-known"></div></div>
|
|
611
|
+
<div class="field"><label for="ar-path">Or any repo</label><div class="addrepo"><input id="ar-path" class="mono" placeholder="~/workspace/spt" spellcheck="false" /><input id="ar-as" class="mono" placeholder="as (folder name)" spellcheck="false" style="max-width:150px" /></div>
|
|
612
|
+
<label class="switch" style="margin-top:6px"><input type="checkbox" id="ar-remember" checked /> also offer it for new workstreams in this workspace</label></div>
|
|
613
|
+
</div>
|
|
614
|
+
<div class="dft"><span class="err" id="ar-err"></span><button id="ar-cancel">Cancel</button><button class="primary" id="ar-go">Add</button></div>
|
|
615
|
+
</dialog>
|
|
616
|
+
|
|
617
|
+
<dialog id="d-card">
|
|
618
|
+
<div class="dhd"><h2>New card</h2></div>
|
|
619
|
+
<div class="dbd">
|
|
620
|
+
<div class="field"><label for="nc-title">Title</label><input id="nc-title" /></div>
|
|
621
|
+
<div class="field"><label for="nc-brief">What needs doing</label><textarea id="nc-brief" rows="5"></textarea></div>
|
|
622
|
+
<div class="field"><label for="nc-who">Who</label><select id="nc-who"></select></div>
|
|
623
|
+
</div>
|
|
624
|
+
<div class="dft"><span class="err" id="nc-err"></span><button id="nc-cancel">Cancel</button><button class="primary" id="nc-go">Add</button></div>
|
|
625
|
+
</dialog>
|
|
626
|
+
|
|
627
|
+
<dialog id="d-price">
|
|
628
|
+
<div class="dhd"><h2>Model prices</h2></div>
|
|
629
|
+
<div class="dbd">
|
|
630
|
+
<div class="note" style="margin-bottom:10px">opencode reports a cost only when the provider returns one (Bedrock does not), so BotFarm prices tokens itself. USD per million tokens; cache reads are charged at a tenth of input, cache writes at 1.25×. Saved in <span class="mono">~/.botfarm/config.json</span>.</div>
|
|
631
|
+
<div id="pr-rows"></div>
|
|
632
|
+
</div>
|
|
633
|
+
<div class="dft"><span class="err" id="pr-err"></span><button id="pr-cancel">Close</button><button class="primary" id="pr-save">Save</button></div>
|
|
634
|
+
</dialog>
|
|
635
|
+
|
|
636
|
+
<dialog id="d-restart">
|
|
637
|
+
<div class="dhd"><h2>Restart this workstream</h2></div>
|
|
638
|
+
<div class="dbd">
|
|
639
|
+
<div class="field"><label for="rs-stage">Start again from</label><select id="rs-stage"></select>
|
|
640
|
+
<div class="hint">Stages before it keep their handoffs. This one and everything after go back to waiting their turn.</div></div>
|
|
641
|
+
<label style="display:flex;gap:8px;align-items:flex-start"><input type="checkbox" id="rs-fresh" checked style="margin-top:4px" />
|
|
642
|
+
<span>Fresh bots<br><span class="note">New opencode sessions in the same worktree and branch, so changes to opencode.json, MCP servers or the agents take effect. The old sessions leave the board; their transcripts stay in opencode. Unticked, the same bots are told to pick up again.</span></span></label>
|
|
643
|
+
</div>
|
|
644
|
+
<div class="dft"><span class="err" id="rs-err"></span><button id="rs-cancel">Cancel</button><button class="primary" id="rs-go">Restart</button></div>
|
|
645
|
+
</dialog>
|
|
646
|
+
|
|
647
|
+
<dialog id="d-delete">
|
|
648
|
+
<div class="dhd"><h2>Delete <span id="dl-name"></span>?</h2></div>
|
|
649
|
+
<div class="dbd">
|
|
650
|
+
<p class="note" id="dl-what" style="margin-top:0"></p>
|
|
651
|
+
<div id="dl-dirty" hidden style="border:1px solid var(--busy);border-radius:8px;padding:10px 12px;margin:10px 0">
|
|
652
|
+
<b style="color:var(--busy)" id="dl-dirty-head"></b>
|
|
653
|
+
<div class="mono note" id="dl-files" style="margin:6px 0;font-size:12px"></div>
|
|
654
|
+
<label style="display:flex;gap:8px;align-items:flex-start"><input type="checkbox" id="dl-force" style="margin-top:4px" /><span>Delete them anyway</span></label>
|
|
655
|
+
</div>
|
|
656
|
+
<label style="display:flex;gap:8px;align-items:flex-start" id="dl-branch-row"><input type="checkbox" id="dl-branch" style="margin-top:4px" />
|
|
657
|
+
<span>Also delete branch <span class="mono" id="dl-branch-name"></span><br><span class="note" id="dl-branch-note"></span></span></label>
|
|
658
|
+
</div>
|
|
659
|
+
<div class="dft"><span class="err" id="dl-err"></span><button id="dl-cancel">Cancel</button><button class="primary danger" id="dl-go">Delete</button></div>
|
|
660
|
+
</dialog>
|
|
661
|
+
|
|
662
|
+
<dialog id="d-apply" style="width:min(820px,96vw)">
|
|
663
|
+
<div class="dhd"><h2>Apply the work to your repos</h2></div>
|
|
664
|
+
<div class="dbd">
|
|
665
|
+
<p class="note" style="margin-top:0">Each repo's changes (edits, new files, and anything committed on the branch) become a patch, applied to your real checkout on whatever branch it is on — <b>unstaged and uncommitted</b>, ready to review in your IDE. BotFarm's own files are left out. The .patch files are kept, so you can do this again later.</p>
|
|
666
|
+
<div id="ap-body"><div class="note">Working out the patches…</div></div>
|
|
667
|
+
</div>
|
|
668
|
+
<div class="dft"><span class="err" id="ap-err"></span><button id="ap-undo" hidden>Undo last apply</button><button id="ap-cancel">Close</button><button class="primary" id="ap-go" disabled>Apply</button></div>
|
|
669
|
+
</dialog>
|
|
670
|
+
|
|
671
|
+
<dialog id="d-limits">
|
|
672
|
+
<div class="dhd"><h2>Workstream limits</h2></div>
|
|
673
|
+
<div class="dbd">
|
|
674
|
+
<p class="note" style="margin-top:0">When the workstream reaches any of these, every bot on it stops and it waits for you. Leave a box empty for no limit. Cards can have their own limits too (set per stage in the pipeline).</p>
|
|
675
|
+
<div id="lm-body"></div>
|
|
676
|
+
<div class="note mono" id="lm-used"></div>
|
|
677
|
+
</div>
|
|
678
|
+
<div class="dft"><span class="err" id="lm-err"></span><button id="lm-cancel">Cancel</button><button class="primary" id="lm-go">Save</button></div>
|
|
679
|
+
</dialog>
|
|
680
|
+
|
|
681
|
+
<dialog id="d-bot">
|
|
682
|
+
<div class="dhd"><h2>Add a bot to this workstream</h2></div>
|
|
683
|
+
<div class="dbd"><div class="field"><label for="nb-agent">Agent</label><select id="nb-agent"></select>
|
|
684
|
+
<div class="hint">It joins the team, the chat and the worktree. Give it work with a card.</div></div></div>
|
|
685
|
+
<div class="dft"><span class="err" id="nb-err"></span><button id="nb-cancel">Cancel</button><button class="primary" id="nb-go">Add</button></div>
|
|
686
|
+
</dialog>
|
|
687
|
+
<script>
|
|
688
|
+
// ------------------------------------------------------------------ basics
|
|
689
|
+
const $ = (s, el = document) => el.querySelector(s)
|
|
690
|
+
const $$ = (s, el = document) => [...el.querySelectorAll(s)]
|
|
691
|
+
const esc = (v) => String(v ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c])
|
|
692
|
+
const fmt = {
|
|
693
|
+
n(v) { if (!v) return "0"; if (v >= 1e6) return (v / 1e6).toFixed(1) + "M"; if (v >= 1e3) return (v / 1e3).toFixed(v >= 1e4 ? 0 : 1) + "k"; return String(Math.round(v)) },
|
|
694
|
+
money(v) { return "$" + (v ?? 0).toFixed(v && v < 1 ? 3 : 2) },
|
|
695
|
+
ago(ts) { if (!ts) return ""; const s = Math.max(0, Math.round((Date.now() - ts) / 1000)); if (s < 60) return s + "s"; if (s < 3600) return Math.floor(s / 60) + "m"; if (s < 86400) return Math.floor(s / 3600) + "h"; return Math.floor(s / 86400) + "d" },
|
|
696
|
+
ms(v) { return v == null ? "" : v < 1000 ? v + "ms" : (v / 1000).toFixed(1) + "s" },
|
|
697
|
+
time(ts) { return ts ? new Date(ts).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) : "" },
|
|
698
|
+
path(p) { return (p ?? "").replace(/^\/(Users|home)\/[^/]+/, "~") },
|
|
699
|
+
}
|
|
700
|
+
const avatarUrl = (s) => `/avatar/${encodeURIComponent(s.id)}.svg?h=${encodeURIComponent(s.handle ?? "")}`
|
|
701
|
+
/** "anthropic/claude-sonnet-4-5" + "high" → "claude-sonnet-4-5 · high". */
|
|
702
|
+
function modelName(s) {
|
|
703
|
+
const m = s.model ?? s.configuredModel
|
|
704
|
+
const short = m ? String(m).split("/").pop().replace(/^(us|eu|apac|global)\.(anthropic\.)?/, "").replace(/-\d{8}(-v\d+(:\d+)?)?$/, "") : "opencode default model"
|
|
705
|
+
return short + (s.variant ? ` · ${s.variant}` : "")
|
|
706
|
+
}
|
|
707
|
+
const slug = (s, max = 48) => {
|
|
708
|
+
const full = String(s ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")
|
|
709
|
+
return full.length <= max ? full : full.slice(0, max).replace(/-[^-]*$/, "")
|
|
710
|
+
}
|
|
711
|
+
async function api(path, opts = {}) {
|
|
712
|
+
const res = await fetch(path, {
|
|
713
|
+
method: opts.method ?? "GET",
|
|
714
|
+
headers: opts.body ? { "content-type": "application/json" } : undefined,
|
|
715
|
+
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
716
|
+
})
|
|
717
|
+
const out = await res.json().catch(() => ({}))
|
|
718
|
+
if (!res.ok) throw new Error(out.error ?? res.statusText)
|
|
719
|
+
return out
|
|
720
|
+
}
|
|
721
|
+
const store = {
|
|
722
|
+
get(k, d) { try { return JSON.parse(localStorage.getItem("bf:" + k)) ?? d } catch { return d } },
|
|
723
|
+
set(k, v) { try { localStorage.setItem("bf:" + k, JSON.stringify(v)) } catch {} },
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// ------------------------------------------------------------------ state
|
|
727
|
+
let state = { sessions: [], rooms: [], projects: [], tasks: [], workspaces: [], events: [], totals: {} }
|
|
728
|
+
let ui = { ws: null, kind: "home", id: null }
|
|
729
|
+
const libs = new Map() // workspace id -> library summary
|
|
730
|
+
let seen = store.get("seen", {}) // room id -> last message time the operator saw
|
|
731
|
+
|
|
732
|
+
function route() {
|
|
733
|
+
const [, ws, kind, id] = (location.hash.slice(1) || "").split("/").map((x) => decodeURIComponent(x || ""))
|
|
734
|
+
ui = { ws: ws || store.get("ws", null), kind: kind || "home", id: id || null }
|
|
735
|
+
try { if (drawer?.kind === "packet") closeDrawer(true) } catch {} // drawer is declared further down
|
|
736
|
+
const known = state.workspaces ?? []
|
|
737
|
+
if (known.length && !known.some((w) => w.id === ui.ws)) ui.ws = known[0].id
|
|
738
|
+
if (ui.ws) store.set("ws", ui.ws)
|
|
739
|
+
mainKey = null
|
|
740
|
+
render()
|
|
741
|
+
}
|
|
742
|
+
const go = (kind, id = null, ws = ui.ws) => { location.hash = `/${encodeURIComponent(ws ?? "")}/${kind}${id ? "/" + encodeURIComponent(id) : ""}` }
|
|
743
|
+
window.addEventListener("hashchange", route)
|
|
744
|
+
|
|
745
|
+
const currentWs = () => (state.workspaces ?? []).find((w) => w.id === ui.ws) ?? null
|
|
746
|
+
const streamsOf = (wsId) => (state.projects ?? [])
|
|
747
|
+
.filter((p) => p.id !== "default" && p.workspaceId === wsId)
|
|
748
|
+
.sort((a, b) => (a.status === "archived") - (b.status === "archived") || (b.created ?? 0) - (a.created ?? 0))
|
|
749
|
+
const tasksOf = (p) => (state.tasks ?? []).filter((t) => t.projectId === p.id && t.status !== "cancelled").sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
|
750
|
+
const roomOf = (p) => (state.rooms ?? []).find((r) => r.id === p.roomId) ?? null
|
|
751
|
+
const teamOf = (p) => (state.sessions ?? []).filter((s) => (s.project ?? "default") === p.id)
|
|
752
|
+
const sessionById = (id) => (state.sessions ?? []).find((s) => s.id === id)
|
|
753
|
+
const needsYou = (t) => !["done", "cancelled", "blocked"].includes(t.status) && (t.owner === "human" || t.status === "review" || t.status === "waiting")
|
|
754
|
+
const unreadOf = (p) => {
|
|
755
|
+
const room = roomOf(p)
|
|
756
|
+
if (!room) return 0
|
|
757
|
+
return room.messages.filter((m) => m.from !== "operator" && m.at > (seen[room.id] ?? 0)).length
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
async function library(wsId, fresh = false) {
|
|
761
|
+
if (!wsId) return null
|
|
762
|
+
if (!fresh && libs.has(wsId)) return libs.get(wsId)
|
|
763
|
+
const lib = await api(`/api/workspaces/${encodeURIComponent(wsId)}/library`)
|
|
764
|
+
libs.set(wsId, lib)
|
|
765
|
+
return lib
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// ------------------------------------------------------------------ sidebar
|
|
769
|
+
let navSig = null
|
|
770
|
+
function renderSide() {
|
|
771
|
+
const ws = currentWs()
|
|
772
|
+
$("#ws-name").textContent = ws?.name ?? "Open a folder"
|
|
773
|
+
$("#ws-path").textContent = ws ? fmt.path(ws.path) : ""
|
|
774
|
+
const t = state.totals ?? {}
|
|
775
|
+
$("#f-busy").textContent = t.busy ?? 0
|
|
776
|
+
$("#f-tpm").textContent = fmt.n(t.tokPerMin)
|
|
777
|
+
$("#f-cost").textContent = fmt.money(t.usd)
|
|
778
|
+
$("#f-tok").textContent = fmt.n(t.tokens)
|
|
779
|
+
$("#f-cost-l").textContent = t.unpriced ? `spend · ${t.unknownModels?.length ?? 1} model(s) unpriced` : "spend"
|
|
780
|
+
$("#f-cost-l").className = t.unpriced ? "warn" : ""
|
|
781
|
+
$("#conn").textContent = state.serverStatus ?? ""
|
|
782
|
+
const lib = ws ? libs.get(ws.id) : null
|
|
783
|
+
const streams = ws ? streamsOf(ws.id) : []
|
|
784
|
+
const wt = ws ? state.tools?.[ws.id] : null
|
|
785
|
+
const sig = JSON.stringify([ui, ws?.id, streams.map((p) => [p.id, p.name, p.status, p.busy, unreadOf(p), tasksOf(p).filter(needsYou).length]), lib?.pipelines?.map((p) => [p.id, p.title]), lib?.agents?.map((a) => [a.id, a.title]), wt?.tools?.map((t) => t.file + t.name), wt?.errors?.map((e) => e.file)])
|
|
786
|
+
if (sig === navSig) return
|
|
787
|
+
navSig = sig
|
|
788
|
+
const cur = (kind, id) => ui.kind === kind && ui.id === id
|
|
789
|
+
$("#nav-streams").innerHTML = streams.length
|
|
790
|
+
? streams.map((p) => {
|
|
791
|
+
const needs = tasksOf(p).filter(needsYou).length
|
|
792
|
+
const unread = unreadOf(p)
|
|
793
|
+
const dot = p.status === "archived" ? "" : p.busy ? "busy" : needs ? "waiting" : p.status === "done" ? "done" : ""
|
|
794
|
+
return `<button class="item ${p.status === "archived" ? "archived" : ""}" data-k="stream" data-id="${esc(p.id)}" aria-current="${cur("stream", p.id)}">
|
|
795
|
+
<span class="dot ${dot}"></span><span class="t">${esc(p.name)}</span>
|
|
796
|
+
${needs ? `<span class="badge" title="needs you">${needs}</span>` : ""}${unread ? `<span class="badge msg" title="unread">${unread}</span>` : ""}</button>`
|
|
797
|
+
}).join("")
|
|
798
|
+
: `<div class="note" style="padding:2px 16px">None yet.</div>`
|
|
799
|
+
$("#nav-pipelines").innerHTML = (lib?.pipelines ?? []).map((p) =>
|
|
800
|
+
`<button class="item" data-k="pipeline" data-id="${esc(p.id)}" aria-current="${cur("pipeline", p.id)}"><span class="hash">▸</span><span class="t">${esc(p.title ?? p.id)}</span></button>`).join("")
|
|
801
|
+
|| `<div class="note" style="padding:2px 16px">${ws ? "Loading…" : ""}</div>`
|
|
802
|
+
$("#nav-agents").innerHTML = (lib?.agents ?? []).map((a) =>
|
|
803
|
+
`<button class="item" data-k="agent" data-id="${esc(a.id)}" aria-current="${cur("agent", a.id)}"><span class="hash">@</span><span class="t">${esc(a.title ?? a.id)}</span></button>`).join("")
|
|
804
|
+
$("#nav-tools").innerHTML = [
|
|
805
|
+
...(wt?.tools ?? []).map((t) => `<button class="item" data-k="tool" data-id="${esc(t.file)}" aria-current="${cur("tool", t.file)}" title="${esc(t.description)}"><span class="hash">ƒ</span><span class="t mono">${esc(t.name)}</span></button>`),
|
|
806
|
+
...(wt?.errors ?? []).map((e) => `<button class="item" data-k="tool" data-id="${esc(e.file)}" aria-current="${cur("tool", e.file)}" title="${esc(e.error)}"><span class="hash" style="color:var(--error)">!</span><span class="t mono" style="color:var(--error)">${esc(e.file)}</span></button>`),
|
|
807
|
+
].join("") || `<div class="note" style="padding:2px 16px">${ws ? "Drop .js files in botfarm/mcps/" : ""}</div>`
|
|
808
|
+
for (const b of $$(".nav .item")) b.onclick = () => go(b.dataset.k, b.dataset.id)
|
|
809
|
+
$("#nav-routing").setAttribute("aria-current", String(ui.kind === "routing"))
|
|
810
|
+
$("#nav-routing").onclick = () => go("routing")
|
|
811
|
+
}
|
|
812
|
+
$("#wspick").onclick = () => {
|
|
813
|
+
const menu = $("#wsmenu")
|
|
814
|
+
menu.hidden = !menu.hidden
|
|
815
|
+
$("#wspick").setAttribute("aria-expanded", String(!menu.hidden))
|
|
816
|
+
if (menu.hidden) return
|
|
817
|
+
menu.innerHTML = (state.workspaces ?? []).map((w) =>
|
|
818
|
+
`<button data-ws="${esc(w.id)}" aria-current="${w.id === ui.ws}">${esc(w.name)} <span class="note mono">${esc(fmt.path(w.path))}</span></button>`).join("") +
|
|
819
|
+
`<button id="ws-open">Open folder…</button>` +
|
|
820
|
+
(currentWs() ? `<button id="ws-forget" class="note">Remove ${esc(currentWs().name)} from this list</button>` : "")
|
|
821
|
+
for (const b of $$("[data-ws]", menu)) b.onclick = () => { menu.hidden = true; go("home", null, b.dataset.ws) }
|
|
822
|
+
$("#ws-open").onclick = () => { menu.hidden = true; openFolder() }
|
|
823
|
+
if ($("#ws-forget")) $("#ws-forget").onclick = async () => {
|
|
824
|
+
if (!confirm("Remove this folder from BotFarm? Nothing on disk is deleted.")) return
|
|
825
|
+
menu.hidden = true
|
|
826
|
+
await api(`/api/workspaces/${encodeURIComponent(ui.ws)}`, { method: "DELETE" })
|
|
827
|
+
store.set("ws", null)
|
|
828
|
+
go("home", null, "")
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
$("#new-stream").onclick = () => openNewStream()
|
|
832
|
+
$("#new-pipeline").onclick = () => go("pipeline", "+new")
|
|
833
|
+
$("#new-agent").onclick = () => go("agent", "+new")
|
|
834
|
+
$("#new-tool").onclick = async () => {
|
|
835
|
+
if (!ui.ws) return
|
|
836
|
+
const name = prompt("Tool name (letters, digits, _ and -). Bots will see it as botfarm_<name>.", "my_tool")
|
|
837
|
+
if (!name) return
|
|
838
|
+
try {
|
|
839
|
+
const out = await api(`/api/workspaces/${encodeURIComponent(ui.ws)}/tools`, { method: "POST", body: { name: name.trim() } })
|
|
840
|
+
go("tool", out.file)
|
|
841
|
+
} catch (e) { alert(e.message) }
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// ------------------------------------------------------------------ main
|
|
845
|
+
let mainKey = null
|
|
846
|
+
function render() {
|
|
847
|
+
renderSide()
|
|
848
|
+
checkHarvests()
|
|
849
|
+
if (ui.kind !== "replay" && rp) { clearInterval(rp.timer); rp = null }
|
|
850
|
+
const ws = currentWs()
|
|
851
|
+
const key = `${ui.ws}|${ui.kind}|${ui.id}`
|
|
852
|
+
const main = $("#main")
|
|
853
|
+
if (!ws) {
|
|
854
|
+
if (mainKey !== "none") {
|
|
855
|
+
mainKey = "none"
|
|
856
|
+
main.innerHTML = `<div class="page"><div class="hero"><h1>Open a folder to start</h1>
|
|
857
|
+
<p class="lead">A workspace is a folder and its opencode config. Its agents and pipelines live in two YAML files at its root.</p>
|
|
858
|
+
<button class="primary" id="hero-open">Open a folder…</button></div></div>`
|
|
859
|
+
$("#hero-open").onclick = openFolder
|
|
860
|
+
}
|
|
861
|
+
return
|
|
862
|
+
}
|
|
863
|
+
if (!libs.has(ws.id)) library(ws.id).then(() => { mainKey = null; navSig = null; render() }).catch(() => {})
|
|
864
|
+
if (ui.kind === "stream") return renderStream(key)
|
|
865
|
+
if (ui.kind === "pipeline") return mainKey === key ? null : renderPipeline(key)
|
|
866
|
+
if (ui.kind === "agent") return mainKey === key ? null : renderAgent(key)
|
|
867
|
+
if (ui.kind === "tool") return renderTool(key)
|
|
868
|
+
if (ui.kind === "replay") return renderReplay(key)
|
|
869
|
+
if (ui.kind === "routing") return mainKey === key ? null : renderRouting(key)
|
|
870
|
+
return renderHome(key)
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// ------------------------------------------------------------------ home
|
|
874
|
+
let homeSig = null
|
|
875
|
+
function renderHome(key) {
|
|
876
|
+
const ws = currentWs()
|
|
877
|
+
const streams = streamsOf(ws.id)
|
|
878
|
+
const lib = libs.get(ws.id)
|
|
879
|
+
const sig = JSON.stringify([key, streams.map((p) => [p.id, p.status, p.busy, roomOf(p)?.messages?.length, tasksOf(p).map((t) => t.status)]), lib?.problems?.length, lib?.missing])
|
|
880
|
+
if (mainKey === key && sig === homeSig) return
|
|
881
|
+
mainKey = key
|
|
882
|
+
homeSig = sig
|
|
883
|
+
const open = streams.filter((p) => p.status !== "archived")
|
|
884
|
+
const archived = streams.filter((p) => p.status === "archived")
|
|
885
|
+
const tile = (p) => {
|
|
886
|
+
const tasks = tasksOf(p)
|
|
887
|
+
const done = tasks.filter((t) => t.status === "done").length
|
|
888
|
+
const needs = tasks.filter(needsYou).length
|
|
889
|
+
const last = roomOf(p)?.messages?.at(-1)
|
|
890
|
+
return `<button class="tile" data-id="${esc(p.id)}">
|
|
891
|
+
<div class="top"><span class="dot ${p.busy ? "busy" : needs ? "waiting" : p.status === "done" ? "done" : ""}"></span><b>${esc(p.name)}</b>
|
|
892
|
+
<span class="chip">${done}/${tasks.length}</span></div>
|
|
893
|
+
<div class="note mono">${esc(p.pipelineTitle ?? p.pipeline ?? "no pipeline")} · ${esc(p.branch ?? "")}</div>
|
|
894
|
+
${last ? `<div class="last"><b class="mono">${last.from === "operator" ? "you" : "@" + esc(last.from)}</b> ${esc(last.text)}</div>` : ""}
|
|
895
|
+
<div class="avs">${teamOf(p).map((s) => `<img src="${avatarUrl(s)}" alt="@${esc(s.handle)}" title="@${esc(s.handle)} · ${esc(s.status)}" />`).join("")}
|
|
896
|
+
${needs ? `<span class="chip" style="color:var(--wait);border-color:var(--wait);margin-left:auto">${needs} need${needs > 1 ? "" : "s"} you</span>` : ""}</div>
|
|
897
|
+
</button>`
|
|
898
|
+
}
|
|
899
|
+
$("#main").innerHTML = `<div class="page">
|
|
900
|
+
<h1>${esc(ws.name)}</h1>
|
|
901
|
+
<p class="lead mono" style="font-size:12.5px">${esc(fmt.path(ws.path))}</p>
|
|
902
|
+
${lib?.problems?.length ? `<div class="problems">${lib.problems.map((p) => `${esc(p.file)}${p.id ? " · " + esc(p.id) : ""}: ${esc(p.problems.join("; "))}`).join("<br>")}</div>` : ""}
|
|
903
|
+
${lib?.opencode ? `<p class="note" style="margin:-8px 0 16px">${lib.opencode.found
|
|
904
|
+
? `opencode.json: model <span class="mono">${esc(lib.opencode.model ?? "—")}</span> · ${lib.opencode.agents.length} agents · MCP ${lib.opencode.mcp.filter((m) => m.enabled).map((m) => esc(m.name)).join(", ") || "none enabled"}`
|
|
905
|
+
: `<span style="color:var(--busy)">No opencode.json in this folder — bots get opencode's defaults.</span>`}</p>` : ""}
|
|
906
|
+
<div class="hero"><div>Each workstream is one story on its own branch, with its own bots, chat and board.</div>
|
|
907
|
+
<button class="primary" id="home-new">Start a workstream</button></div>
|
|
908
|
+
<div class="grid">${open.map(tile).join("")}</div>
|
|
909
|
+
<div id="home-harvest"></div>
|
|
910
|
+
${archived.length ? `<h3 class="note" style="margin:24px 0 8px">Archived</h3><div class="grid">${archived.map(tile).join("")}</div>` : ""}
|
|
911
|
+
</div>`
|
|
912
|
+
harvestPanel(ws.id)
|
|
913
|
+
$("#home-new").onclick = () => openNewStream()
|
|
914
|
+
for (const t of $$(".tile[data-id]")) t.onclick = () => go("stream", t.dataset.id)
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// ------------------------------------------------------------------ workstream
|
|
918
|
+
const COLS = [
|
|
919
|
+
{ id: "todo", label: "To do" },
|
|
920
|
+
{ id: "doing", label: "Doing" },
|
|
921
|
+
{ id: "needs", label: "Needs you" },
|
|
922
|
+
{ id: "done", label: "Done" },
|
|
923
|
+
]
|
|
924
|
+
function colOf(t) {
|
|
925
|
+
if (t.status === "done") return "done"
|
|
926
|
+
if (needsYou(t)) return "needs"
|
|
927
|
+
if (t.status === "active") return "doing"
|
|
928
|
+
return "todo"
|
|
929
|
+
}
|
|
930
|
+
const canSendBack = (t) => !!t?.runId && t.status === "done" && t.owner !== "human" && t.kind !== "question"
|
|
931
|
+
async function sendBackPrompt(t) {
|
|
932
|
+
const reason = prompt(`Send "${t.title}" back to its bot?\n\nWhat needs fixing? (They get this card back with your words, and whatever was waiting on it waits for the fix.)`)
|
|
933
|
+
if (!reason?.trim()) return false
|
|
934
|
+
await api(`/api/tasks/${encodeURIComponent(t.id)}/send-back`, { method: "POST", body: { reason } }).catch((e) => alert(e.message))
|
|
935
|
+
return true
|
|
936
|
+
}
|
|
937
|
+
function cardStatus(t, tasks) {
|
|
938
|
+
if (t.status === "blocked" && t.recheck) return `waits on the fix to ${t.recheck.to} · fix ${t.recheck.round}`
|
|
939
|
+
if (t.status === "queued" && t.rework && !t.rework.early) return `sent back by @${t.rework.by}${t.prefer ? ` · for @${sessionById(t.prefer)?.handle ?? "its bot"}` : ""}`
|
|
940
|
+
if (t.status === "blocked") {
|
|
941
|
+
const dep = tasks.find((x) => t.dependsOn?.includes(x.id))
|
|
942
|
+
return `waits on ${dep?.stage ?? "another card"}`
|
|
943
|
+
}
|
|
944
|
+
if (t.status === "active") return `working ${fmt.ago(t.startedAt ?? t.updated)}`
|
|
945
|
+
if (t.kind === "question" && t.status !== "done") return "question for you"
|
|
946
|
+
if (t.status === "review") return t.handoff?.open_questions?.length ? `${t.handoff.open_questions.length} open question(s)` : "review"
|
|
947
|
+
if (t.status === "done") return t.owner === "human" ? "answered" : "handed off"
|
|
948
|
+
return t.status
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
let streamSig = {}
|
|
952
|
+
function renderStream(key) {
|
|
953
|
+
const p = (state.projects ?? []).find((x) => x.id === ui.id)
|
|
954
|
+
if (!p) { if (mainKey !== key + "missing") { mainKey = key + "missing"; $("#main").innerHTML = `<div class="page"><div class="empty">That workstream is gone.</div></div>` } return }
|
|
955
|
+
if (mainKey !== key) {
|
|
956
|
+
mainKey = key
|
|
957
|
+
streamSig = {}
|
|
958
|
+
$("#main").innerHTML = `
|
|
959
|
+
<div class="whead">
|
|
960
|
+
<div class="row1"><span class="dot" id="w-dot"></span><h1 id="w-title"></h1><span class="chip" id="w-branch"></span>
|
|
961
|
+
<div class="acts">
|
|
962
|
+
<button class="sm" id="w-card">+ Card</button>
|
|
963
|
+
<button class="sm ghost" id="w-story">Story</button>
|
|
964
|
+
<button class="sm ghost" id="w-repo" title="Put another repo in this workstream's worktree, on the same branch">+ Repo</button>
|
|
965
|
+
<button class="sm ghost" id="w-pr" title="A PR description from the handoffs: criteria with their tests, files, cost">PR packet</button>
|
|
966
|
+
<button class="sm ghost" id="w-apply" title="Turn the work into patches and apply them to your real repos, uncommitted">Apply to repos…</button>
|
|
967
|
+
<button class="sm ghost" id="w-replay" title="Scrub through everything that happened; save it as a file">Replay</button>
|
|
968
|
+
<button class="sm" id="w-pause" title="Stop every bot on this workstream until you resume it" hidden>Pause</button>
|
|
969
|
+
<button class="sm ghost" id="w-limits" title="Time, token and dollar limits for this workstream">Limits…</button>
|
|
970
|
+
<button class="sm ghost" id="w-refresh" title="Re-read opencode.json and the botfarm/ agents, reconnect MCP servers and fetch their current tools">Refresh tools</button>
|
|
971
|
+
<button class="sm ghost" id="w-restart">Restart…</button>
|
|
972
|
+
<button class="sm ghost danger" id="w-archive">Archive</button>
|
|
973
|
+
<button class="sm ghost danger" id="w-delete" title="Delete this workstream for good">Delete…</button>
|
|
974
|
+
</div></div>
|
|
975
|
+
<div class="row2"><div class="steps" id="w-steps"></div><span id="w-budget"></span>
|
|
976
|
+
<div class="viewtabs" style="margin-left:auto" role="group" aria-label="View"><button data-view="board" aria-pressed="true">Board</button><button data-view="farm" aria-pressed="false">Farm</button></div></div>
|
|
977
|
+
</div>
|
|
978
|
+
<div id="w-paused"></div>
|
|
979
|
+
<div class="wlayout">
|
|
980
|
+
<aside class="rail" aria-label="Team">
|
|
981
|
+
<div class="rh">Team <span class="mono" id="w-teamn"></span><button class="sm ghost" id="w-bot" title="Add a bot to this workstream">+ Bot</button></div>
|
|
982
|
+
<div class="list" id="w-team"></div>
|
|
983
|
+
</aside>
|
|
984
|
+
<div class="wmain">
|
|
985
|
+
<div class="wbody">
|
|
986
|
+
<div class="board" id="w-board"></div>
|
|
987
|
+
<div class="farm" id="w-farm" hidden></div>
|
|
988
|
+
<div class="splitter" id="w-split" role="separator" aria-orientation="horizontal" title="Drag to resize"></div>
|
|
989
|
+
<section class="chat">
|
|
990
|
+
<div class="ch"><b id="w-room"></b><span class="note" id="w-members"></span></div>
|
|
991
|
+
<div class="log" id="w-log"></div>
|
|
992
|
+
<div class="composer">
|
|
993
|
+
<div class="mentions" id="w-mentions" hidden></div>
|
|
994
|
+
<textarea id="w-input" rows="1" placeholder="Message the team… @handle to reach one bot"></textarea>
|
|
995
|
+
<button class="primary" id="w-send">Post</button>
|
|
996
|
+
</div>
|
|
997
|
+
</section>
|
|
998
|
+
</div>
|
|
999
|
+
</div>
|
|
1000
|
+
</div>`
|
|
1001
|
+
$("#w-card").onclick = () => openNewCard(p.id)
|
|
1002
|
+
$("#w-bot").onclick = () => openAddBot(p.id)
|
|
1003
|
+
$("#w-story").onclick = () => openStory(p.id)
|
|
1004
|
+
$("#w-restart").onclick = () => openRestart(p.id)
|
|
1005
|
+
$("#w-archive").onclick = async () => {
|
|
1006
|
+
if (!confirm(`Archive "${p.name}"? Its bots are stopped and its open cards cancelled. The chat and board stay readable.`)) return
|
|
1007
|
+
const drop = confirm("Also delete its opencode sessions and its worktree? Cancel keeps them.")
|
|
1008
|
+
await api(`/api/projects/${encodeURIComponent(p.id)}/archive`, { method: "POST", body: { removeSessions: drop, removeWorktree: drop } }).catch((e) => alert(e.message))
|
|
1009
|
+
}
|
|
1010
|
+
$("#w-delete").onclick = () => openDelete(p.id)
|
|
1011
|
+
$("#w-limits").onclick = () => openLimits(p.id)
|
|
1012
|
+
$("#w-pause").onclick = async () => {
|
|
1013
|
+
const cur = (state.projects ?? []).find((x) => x.id === p.id)
|
|
1014
|
+
const paused = !!cur?.budget?.paused
|
|
1015
|
+
$("#w-pause").disabled = true
|
|
1016
|
+
await api(`/api/projects/${encodeURIComponent(p.id)}/${paused ? "resume" : "pause"}`, { method: "POST", body: {} }).catch((e) => alert(e.message))
|
|
1017
|
+
$("#w-pause").disabled = false
|
|
1018
|
+
}
|
|
1019
|
+
$("#w-pr").onclick = () => openPacket(p.id)
|
|
1020
|
+
$("#w-apply").onclick = () => openApply(p.id)
|
|
1021
|
+
$("#w-replay").onclick = () => go("replay", "p:" + p.id)
|
|
1022
|
+
$("#w-repo").onclick = () => openAddRepo(p.id)
|
|
1023
|
+
for (const b of $$("[data-view]")) b.onclick = () => { store.set("view", b.dataset.view); applyView(); streamSig = {}; renderStream(mainKey) }
|
|
1024
|
+
applyView()
|
|
1025
|
+
$("#w-refresh").onclick = async () => {
|
|
1026
|
+
const b = $("#w-refresh")
|
|
1027
|
+
b.disabled = true
|
|
1028
|
+
b.textContent = "Refreshing…"
|
|
1029
|
+
try {
|
|
1030
|
+
const out = await api(`/api/projects/${encodeURIComponent(p.id)}/refresh-config`, { method: "POST", body: {} })
|
|
1031
|
+
b.textContent = out.pending ? "Refresh when idle" : "Refreshed"
|
|
1032
|
+
} catch (e) { alert(e.message); b.textContent = "Refresh tools" }
|
|
1033
|
+
setTimeout(() => { b.disabled = false; b.textContent = "Refresh tools" }, 2500)
|
|
1034
|
+
}
|
|
1035
|
+
wireComposer(p)
|
|
1036
|
+
wireSplit()
|
|
1037
|
+
}
|
|
1038
|
+
const tasks = tasksOf(p)
|
|
1039
|
+
const team = teamOf(p)
|
|
1040
|
+
const room = roomOf(p)
|
|
1041
|
+
|
|
1042
|
+
// header
|
|
1043
|
+
const hsig = JSON.stringify([p.name, p.branch, p.status, p.busy, tasks.map((t) => [t.id, t.status, t.stage, t.children?.length])])
|
|
1044
|
+
if (hsig !== streamSig.h) {
|
|
1045
|
+
streamSig.h = hsig
|
|
1046
|
+
$("#w-title").textContent = p.name
|
|
1047
|
+
$("#w-branch").textContent = p.branch ?? "no branch"
|
|
1048
|
+
$("#w-branch").title = p.worktree ?? ""
|
|
1049
|
+
$("#w-dot").className = `dot ${p.busy ? "busy" : tasks.some(needsYou) ? "waiting" : p.status === "done" ? "done" : ""}`
|
|
1050
|
+
const stages = tasks.filter((t) => t.stage && !t.parentId)
|
|
1051
|
+
const pieces = (t) => { const k = t.children?.map((id) => tasks.find((x) => x.id === id)).filter(Boolean) ?? []; return k.length ? ` ${k.filter((x) => x.status === "done").length}/${k.length}` : "" }
|
|
1052
|
+
$("#w-steps").innerHTML = stages.length
|
|
1053
|
+
? stages.map((t, i) => {
|
|
1054
|
+
// Stages that start together (after:) sit side by side, joined by ∥ instead of →.
|
|
1055
|
+
const sameStart = i && JSON.stringify(t.dependsOn ?? []) === JSON.stringify(stages[i - 1].dependsOn ?? []) && (t.dependsOn ?? []).length
|
|
1056
|
+
return `${i ? `<span class="arr">${sameStart ? "∥" : "→"}</span>` : ""}<span class="step ${t.status === "done" ? "done" : t.status === "active" ? "active" : needsYou(t) ? "needs" : ""}" title="${esc(t.title)} — ${esc(t.status)}">${esc(t.stage)}${pieces(t)}</span>`
|
|
1057
|
+
}).join("")
|
|
1058
|
+
+ `<span class="note" style="margin-left:8px">${esc(p.pipelineTitle ?? "")}</span>`
|
|
1059
|
+
: `<span class="note">No pipeline — add cards and bots by hand.</span>`
|
|
1060
|
+
$("#w-archive").hidden = p.status === "archived"
|
|
1061
|
+
$("#w-restart").hidden = !p.runId
|
|
1062
|
+
}
|
|
1063
|
+
// team, down the left
|
|
1064
|
+
const tsig = JSON.stringify(team.map((s) => [s.id, s.status, s.currentTool?.name, s.lastText, s.permissions?.length, s.questions?.length, s.model, s.variant, s.tokens, s.cost?.usd, s.invocations, s.totals?.tools, s.mcp?.connected, s.mcp?.total, s.mcp?.botfarm, s.lastError, s.override?.model, s.override?.variant, levelOfBot(s)?.level, levelOfBot(s)?.xp]))
|
|
1065
|
+
if (tsig !== streamSig.t) {
|
|
1066
|
+
streamSig.t = tsig
|
|
1067
|
+
renderRail(team, tasks)
|
|
1068
|
+
}
|
|
1069
|
+
// budget
|
|
1070
|
+
const gsig = JSON.stringify([p.budget?.limits, p.budget?.paused, p.budget ? Math.floor(p.budget.used.minutes) : 0, p.budget ? Math.round(p.budget.used.usd * 100) : 0, p.budget ? Math.round(p.budget.used.tokens / 1000) : 0])
|
|
1071
|
+
if (gsig !== streamSig.g) {
|
|
1072
|
+
streamSig.g = gsig
|
|
1073
|
+
renderBudget(p)
|
|
1074
|
+
}
|
|
1075
|
+
// board
|
|
1076
|
+
const bsig = JSON.stringify([view(), tasks.map((t) => [t.id, t.status, t.title, t.assignee, t.owner, t.startedAt ? Math.floor((Date.now() - t.startedAt) / 60000) : 0, t.budget?.kind, t.limits, t.usage ? Math.round(usageFrac(t) * 50) : 0, t.difficulty?.level, t.xp?.gained])])
|
|
1077
|
+
if (bsig !== streamSig.b) {
|
|
1078
|
+
streamSig.b = bsig
|
|
1079
|
+
if (view() === "board") renderBoard(p, tasks)
|
|
1080
|
+
}
|
|
1081
|
+
if (view() === "farm") renderFarm(p, tasks, team)
|
|
1082
|
+
// chat
|
|
1083
|
+
const events = (state.events ?? []).filter((e) => e.sessionId && team.some((s) => s.id === e.sessionId))
|
|
1084
|
+
const pending = tasks.filter(asks)
|
|
1085
|
+
const botAsks = team.filter((s) => s.permissions?.length || s.questions?.length)
|
|
1086
|
+
const csig = JSON.stringify([room?.id, room?.messages?.length, room?.messages?.at(-1)?.id, events.length, events[0]?.at, pending.map((t) => [t.id, t.status]), botAsks.map((s) => [s.id, s.permissions?.map((x) => x.id), s.questions?.map((x) => x.id)])])
|
|
1087
|
+
// Never repaint under someone answering a question; catch up when they leave it.
|
|
1088
|
+
const typing = document.activeElement?.closest?.("#w-log .ask")
|
|
1089
|
+
if (csig !== streamSig.c && !typing) {
|
|
1090
|
+
streamSig.c = csig
|
|
1091
|
+
renderLog(p, room, events, team, pending)
|
|
1092
|
+
}
|
|
1093
|
+
if (room && document.visibilityState === "visible") {
|
|
1094
|
+
const lastAt = room.messages.at(-1)?.at ?? 0
|
|
1095
|
+
if (lastAt > (seen[room.id] ?? 0)) { seen[room.id] = lastAt; store.set("seen", seen); navSig = null }
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
// --- board or farm
|
|
1101
|
+
const view = () => (store.get("view", "board") === "farm" ? "farm" : "board")
|
|
1102
|
+
function applyView() {
|
|
1103
|
+
const v = view()
|
|
1104
|
+
for (const b of $$("[data-view]")) b.setAttribute("aria-pressed", String(b.dataset.view === v))
|
|
1105
|
+
if ($("#w-board")) $("#w-board").hidden = v !== "board"
|
|
1106
|
+
if ($("#w-farm")) $("#w-farm").hidden = v !== "farm"
|
|
1107
|
+
farmState.built = false
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
// --- the team, down the left: one row per bot, grouped when there are several of a kind
|
|
1111
|
+
const seenBots = new Set()
|
|
1112
|
+
function botDoing(s) {
|
|
1113
|
+
if (s.status === "busy") return s.currentTool ? `running ${s.currentTool.name}` : "thinking…"
|
|
1114
|
+
if (s.status === "waiting") return s.permissions?.length ? `⏸ wants to ${permissionWhat(s.permissions[0]).action}` : s.questions?.length ? "⏸ has a question for you" : "needs a permission"
|
|
1115
|
+
if (s.status === "error") return `⚠ ${s.lastError ?? "error"}`
|
|
1116
|
+
return s.lastText || "idle"
|
|
1117
|
+
}
|
|
1118
|
+
function renderRail(team, tasks) {
|
|
1119
|
+
team = [...team].sort((a, b) => (a.created ?? 0) - (b.created ?? 0))
|
|
1120
|
+
$("#w-teamn").textContent = team.length ? String(team.length) : ""
|
|
1121
|
+
const kinds = new Map()
|
|
1122
|
+
for (const s of team) {
|
|
1123
|
+
const k = s.personaTitle ?? s.persona ?? "Other"
|
|
1124
|
+
kinds.set(k, [...(kinds.get(k) ?? []), s])
|
|
1125
|
+
}
|
|
1126
|
+
const row = (s) => {
|
|
1127
|
+
const card = tasks.find((t) => t.assignee === s.id && t.status === "active")
|
|
1128
|
+
const mcp = s.mcp
|
|
1129
|
+
const fresh = !seenBots.has(s.id)
|
|
1130
|
+
seenBots.add(s.id)
|
|
1131
|
+
return `<div class="rbot ${esc(s.status)} ${fresh && seenBots.size > team.length ? "enter" : ""}" data-s="${esc(s.id)}" style="--fg:${esc(s.color?.fg ?? "")}" title="${esc(botDoing(s))}">
|
|
1132
|
+
<span class="ava"><img src="${avatarUrl(s)}" alt="" /><span class="pip"></span>${levelOfBot(s)?.hat ? `<span class="hat" title="${esc(levelOfBot(s).title)}">${levelOfBot(s).hat}</span>` : ""}</span>
|
|
1133
|
+
<div class="who">
|
|
1134
|
+
<span class="top"><span class="h ${levelOfBot(s)?.level >= 6 ? "gold" : ""}">@${esc(s.handle)}</span>${s.personaTitle || s.persona ? `<span class="kind">${esc(s.personaTitle ?? s.persona)}</span>` : ""}${levelOfBot(s) ? `<span class="lvl" title="${esc(levelOfBot(s).title)} · ${levelOfBot(s).xp} XP${levelOfBot(s).next ? ` · ${levelOfBot(s).next - levelOfBot(s).xp} to the next level` : ""}${(levelOfBot(s).perks ?? []).length ? "\n" + levelOfBot(s).perks.map((x) => "• " + x.label).join("\n") : ""}">Lv ${levelOfBot(s).level}</span>` : ""}</span>
|
|
1135
|
+
<span class="d">${card ? `${esc(card.item ? `${card.item.index}/${card.item.total} ` : "")}${esc(card.title)} · ` : ""}${esc(botDoing(s))}</span>
|
|
1136
|
+
<span class="model ${s.override ? "ovr" : ""}" title="${esc([s.model && `ran on ${s.model}`, s.configuredModel && `asks for ${s.configuredModel}`, s.override && "changed here (Model… in its panel)"].filter(Boolean).join("\n"))}">${s.override ? "⇅ " : ""}${esc(modelName(s))}${s.agentName ? ` · ${esc(s.agentName)}` : ""}</span>
|
|
1137
|
+
<span class="stats">
|
|
1138
|
+
<span title="tokens used"><b>${fmt.n(s.tokens)}</b> tok</span>
|
|
1139
|
+
<span title="${s.cost?.unknownModel ? "no price for " + esc(s.cost.unknownModel) : s.cost?.estimated ? "estimated from tokens" : "reported by the provider"}"><b>${s.cost?.unknownModel ? "$?" : fmt.money(s.cost?.usd)}</b></span>
|
|
1140
|
+
<span title="times it was handed something"><b>${s.invocations ?? 0}</b>×</span>
|
|
1141
|
+
<span title="tool calls"><b>${s.totals?.tools ?? 0}</b> tools</span>
|
|
1142
|
+
${mcp ? `<span class="${mcp.botfarm ? "" : "bad"}" title="${esc(mcp.servers.map((m) => `${m.name}: ${m.status}${m.error ? " — " + m.error : ""}`).join("\n"))}"><b>${mcp.connected}/${mcp.total}</b> MCP</span>` : ""}
|
|
1143
|
+
</span></div></div>`
|
|
1144
|
+
}
|
|
1145
|
+
$("#w-team").innerHTML = team.length
|
|
1146
|
+
? [...kinds.entries()].map(([k, list]) => (kinds.size > 1 || list.length > 1 ? `<div class="grp">${esc(k)}${list.length > 1 ? ` ×${list.length}` : ""}</div>` : "") + list.map(row).join("")).join("")
|
|
1147
|
+
: `<span class="note" style="padding:8px">No bots yet.</span>`
|
|
1148
|
+
for (const b of $$("#w-team .rbot")) b.onclick = () => openSession(b.dataset.s)
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// --- card completion events in the chat: who, how long, tokens, cost, the diff, screenshots
|
|
1152
|
+
const reports = new Map() // task id -> report (diffs), fetched when opened
|
|
1153
|
+
const openCards = new Set() // task ids whose files are open in the chat
|
|
1154
|
+
const openFiles = new Set() // `${taskId}|${file}` open diffs
|
|
1155
|
+
function diffHtml(d) {
|
|
1156
|
+
return `<pre class="diff">${esc(d).split("\n").map((l) => {
|
|
1157
|
+
const c = l.startsWith("+++") || l.startsWith("---") ? "h" : l.startsWith("+") ? "a" : l.startsWith("-") ? "r" : l.startsWith("@@") ? "at" : ""
|
|
1158
|
+
return c ? `<span class="${c}">${l}</span>` : l
|
|
1159
|
+
}).join("\n")}</pre>`
|
|
1160
|
+
}
|
|
1161
|
+
function cardFilesHtml(taskId) {
|
|
1162
|
+
const r = reports.get(taskId)
|
|
1163
|
+
if (!r) return `<div class="note">Loading…</div>`
|
|
1164
|
+
if (!r.diffs?.length) return `<div class="note">${r.files?.length ? "No diff — the files may be committed or unchanged now." : "It did not list any files."}</div>`
|
|
1165
|
+
return r.diffs.map((d) => `<details class="dfile" data-dfile="${esc(taskId)}|${esc(d.file)}" ${openFiles.has(taskId + "|" + d.file) ? "open" : ""}>
|
|
1166
|
+
<summary><span class="mono">${esc(d.file)}</span>${d.created ? ` <span class="fbadge">new</span>` : ""}${d.deleted ? ` <span class="fbadge del">deleted</span>` : ""} <span class="add">+${d.added}</span> <span class="del">−${d.removed}</span>${d.binary ? ` <span class="note">(binary)</span>` : d.truncated ? ` <span class="note">(cut short)</span>` : ""}${d.unlisted ? ` <span class="fbadge warn" title="Changed on disk while this card ran, but not listed in its handoff — by a tool, an MCP server, a script, or another bot in the same worktree">not in its handoff</span>` : ""}</summary>${d.binary ? "" : diffHtml(d.diff)}</details>`).join("") + (r.tracked ? `<div class="note" style="margin-top:4px">Every file that changed on disk while the card ran, whatever changed it.</div>` : "")
|
|
1167
|
+
}
|
|
1168
|
+
function cardEventHtml(m, who, text, isNew) {
|
|
1169
|
+
const e = m.meta
|
|
1170
|
+
text = String(m.text ?? "").replace(/^Finished [^:]*(\([^)]*\))?[^:]*:\s*/, "")
|
|
1171
|
+
const op = m.from === "operator"
|
|
1172
|
+
const added = e.files.reduce((a, f) => a + (f.added ?? 0), 0), removed = e.files.reduce((a, f) => a + (f.removed ?? 0), 0)
|
|
1173
|
+
const style = !op && who?.color ? `style="--fg:${esc(who.color.fg)};--bg:${esc(who.color.bg)}"` : ""
|
|
1174
|
+
const mins = e.minutes < 1 ? `${Math.max(1, Math.round(e.minutes * 60))}s` : e.minutes < 90 ? `${Math.round(e.minutes)}m` : `${(e.minutes / 60).toFixed(1)}h`
|
|
1175
|
+
return `<div class="cardev ${isNew ? "enter" : ""} ${op ? "sysev" : ""}" ${style} data-cardev="${esc(e.taskId)}">
|
|
1176
|
+
<div class="ceh">${op ? `<span class="cebig">🧺</span>` : `<img class="av" src="${avatarUrl({ id: m.fromId ?? m.from, handle: who?.handle ?? m.from })}" alt="" />`}
|
|
1177
|
+
<div class="cet"><div><b class="who">${op ? (e.bots?.length ? e.bots.map((h) => "@" + esc(h)).join(", ") : "The team") : "@" + esc(m.from)}</b> finished ${e.stage ? `<span class="stage">${esc(e.stage)}</span>` : ""}${e.piece ? ` <span class="note">piece ${esc(e.piece)}</span>` : ""}${e.pieces ? ` <span class="note">${e.pieces} pieces</span>` : ""} <span class="note">${fmt.time(m.at)}</span></div>
|
|
1178
|
+
<div class="cettl">✓ ${esc(e.title)}</div></div></div>
|
|
1179
|
+
<div class="cechips">
|
|
1180
|
+
<span title="time on the card">⏱ ${mins}</span>
|
|
1181
|
+
<span title="tokens">${fmt.n(e.tokens)} tok</span>
|
|
1182
|
+
<span title="cost (estimated)">${fmt.money(e.usd)}</span>
|
|
1183
|
+
${e.model ? `<span class="mono" title="${esc(e.model)}">${esc(shortModel(e.model))}</span>` : ""}
|
|
1184
|
+
${e.xp ? `<span class="xp">+${e.xp} XP</span>` : ""}
|
|
1185
|
+
${e.files.length ? `<span>${e.files.length} file${e.files.length > 1 ? "s" : ""}${added || removed ? ` <span class="add">+${added}</span> <span class="del">−${removed}</span>` : ""}</span>` : ""}
|
|
1186
|
+
${e.images ? `<span>🖼 ${e.images}</span>` : ""}
|
|
1187
|
+
${e.open ? `<span class="warnc">${e.open} open question${e.open > 1 ? "s" : ""}</span>` : ""}
|
|
1188
|
+
</div>
|
|
1189
|
+
${text && !op ? `<div class="cesum ${text.length > 400 && !expanded.has(m.id) ? "long" : ""}">${esc(text)}</div>${text.length > 400 ? `<button class="more" data-more="${esc(m.id)}">${expanded.has(m.id) ? "show less" : "show all"}</button>` : ""}` : ""}
|
|
1190
|
+
${e.images ? `<div class="shots">${Array.from({ length: Math.min(e.images, 8) }, (_, i) => `<img src="/api/tasks/${encodeURIComponent(e.taskId)}/image/${i}" alt="screenshot ${i + 1}" loading="lazy" data-shot="/api/tasks/${encodeURIComponent(e.taskId)}/image/${i}" />`).join("")}</div>` : ""}
|
|
1191
|
+
${e.files.length ? `<details class="cefiles" data-cefiles="${esc(e.taskId)}" ${openCards.has(e.taskId) ? "open" : ""}><summary>Changes</summary><div class="difflist">${openCards.has(e.taskId) ? cardFilesHtml(e.taskId) : ""}</div></details>` : ""}
|
|
1192
|
+
</div>`
|
|
1193
|
+
}
|
|
1194
|
+
function wireCardEvents(root) {
|
|
1195
|
+
for (const d of $$("[data-cefiles]", root)) d.ontoggle = async () => {
|
|
1196
|
+
const id = d.dataset.cefiles
|
|
1197
|
+
if (!d.open) { openCards.delete(id); return }
|
|
1198
|
+
openCards.add(id)
|
|
1199
|
+
const box = $(".difflist", d)
|
|
1200
|
+
if (!reports.has(id)) {
|
|
1201
|
+
box.innerHTML = `<div class="note">Loading…</div>`
|
|
1202
|
+
try { reports.set(id, await api(`/api/tasks/${encodeURIComponent(id)}/report`)) } catch (e) { box.innerHTML = `<div class="err">${esc(e.message)}</div>`; return }
|
|
1203
|
+
}
|
|
1204
|
+
box.innerHTML = cardFilesHtml(id)
|
|
1205
|
+
wireDiffFiles(box)
|
|
1206
|
+
}
|
|
1207
|
+
for (const d of $$("[data-cefiles][open] .difflist", root)) wireDiffFiles(d)
|
|
1208
|
+
for (const im of $$("[data-shot]", root)) im.onclick = () => lightbox(im.dataset.shot)
|
|
1209
|
+
// Reports opened before a repaint come back open without a refetch.
|
|
1210
|
+
for (const d of $$("[data-cefiles][open]", root)) if (!reports.has(d.dataset.cefiles)) d.ontoggle()
|
|
1211
|
+
}
|
|
1212
|
+
function wireDiffFiles(box) {
|
|
1213
|
+
for (const f of $$("[data-dfile]", box)) f.ontoggle = () => (f.open ? openFiles.add(f.dataset.dfile) : openFiles.delete(f.dataset.dfile))
|
|
1214
|
+
}
|
|
1215
|
+
function lightbox(src) {
|
|
1216
|
+
const el = document.createElement("div")
|
|
1217
|
+
el.className = "lightbox"
|
|
1218
|
+
el.innerHTML = `<img src="${esc(src)}" alt="" /><button class="sm" aria-label="Close">Close</button>`
|
|
1219
|
+
el.onclick = () => el.remove()
|
|
1220
|
+
document.body.appendChild(el)
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// --- limits: time, tokens and dollars, for the workstream and for each card
|
|
1224
|
+
const LIMIT_KINDS = [["usd", "$", (v) => fmt.money(v)], ["tokens", "tok", (v) => fmt.n(v)], ["minutes", "min", (v) => `${Math.round(v)}m`]]
|
|
1225
|
+
function usageFrac(t) {
|
|
1226
|
+
if (!t.limits || !t.usage) return 0
|
|
1227
|
+
return Math.max(0, ...Object.entries(t.limits).map(([k, v]) => (t.usage[k] ?? 0) / v))
|
|
1228
|
+
}
|
|
1229
|
+
function renderBudget(p) {
|
|
1230
|
+
const b = p.budget
|
|
1231
|
+
const el = $("#w-budget")
|
|
1232
|
+
if (!b) { el.innerHTML = ""; $("#w-paused").innerHTML = ""; $("#w-limits").hidden = true; return }
|
|
1233
|
+
$("#w-limits").hidden = false
|
|
1234
|
+
const set = LIMIT_KINDS.filter(([k]) => b.limits?.[k])
|
|
1235
|
+
el.innerHTML = `<span class="budget" title="What this workstream has used${set.length ? " against its limits" : ""}">
|
|
1236
|
+
${(set.length ? set : LIMIT_KINDS).map(([k, unit, f]) => {
|
|
1237
|
+
const lim = b.limits?.[k]
|
|
1238
|
+
const frac = lim ? Math.min(1, (b.used[k] ?? 0) / lim) : 0
|
|
1239
|
+
return `<span class="m">${f(b.used[k] ?? 0)}${lim ? ` / ${f(lim)}` : k === "tokens" ? " tok" : ""}${lim ? `<span class="bar"><i class="${frac >= 1 ? "over" : frac > .8 ? "warn" : ""}" style="width:${Math.round(frac * 100)}%"></i></span>` : ""}</span>`
|
|
1240
|
+
}).join("")}
|
|
1241
|
+
<button class="ghost" data-limits>${set.length ? "Limits" : "Set limits"}</button></span>`
|
|
1242
|
+
$("[data-limits]", el).onclick = () => openLimits(p.id)
|
|
1243
|
+
const pz = b.paused
|
|
1244
|
+
const pb = $("#w-pause")
|
|
1245
|
+
if (pb) { pb.hidden = b.status !== "running"; pb.textContent = pz ? "▶ Resume" : "⏸ Pause"; pb.className = pz ? "sm primary" : "sm" }
|
|
1246
|
+
if (pz?.kind === "manual") {
|
|
1247
|
+
$("#w-paused").innerHTML = `<div class="paused" style="margin-top:10px">⏸ <span><b>Paused by you</b> ${fmt.ago(pz.at)} ago — ${pz.interrupted?.length ?? 0} bot(s) stopped mid-card; nothing new starts. Resume picks them up where they were.</span>
|
|
1248
|
+
<span class="acts"><button class="primary sm" data-resume>▶ Resume</button></span></div>`
|
|
1249
|
+
$("[data-resume]", $("#w-paused")).onclick = () => $("#w-pause").click()
|
|
1250
|
+
return
|
|
1251
|
+
}
|
|
1252
|
+
$("#w-paused").innerHTML = pz ? `<div class="paused" style="margin-top:10px">⏸ <span><b>Paused:</b> reached its ${pz.kind === "usd" ? "dollar" : pz.kind === "tokens" ? "token" : "time"} limit
|
|
1253
|
+
(${LIMIT_KINDS.find(([k]) => k === pz.kind)[2](pz.used)} of ${LIMIT_KINDS.find(([k]) => k === pz.kind)[2](pz.limit)}). Every bot is stopped until you raise it.</span>
|
|
1254
|
+
<span class="acts"><button class="primary sm" data-raise>Raise limit and resume…</button><button class="sm danger" data-stop>Stop workstream</button></span></div>` : ""
|
|
1255
|
+
if (pz) {
|
|
1256
|
+
$("[data-raise]", $("#w-paused")).onclick = () => openLimits(p.id)
|
|
1257
|
+
$("[data-stop]", $("#w-paused")).onclick = async () => {
|
|
1258
|
+
if (!confirm("Stop this workstream? Open cards are cancelled; the chat, board and branch stay.")) return
|
|
1259
|
+
await api(`/api/projects/${encodeURIComponent(p.id)}/budget`, { method: "POST", body: { action: "stop" } }).catch((e) => alert(e.message))
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
function limitInputs(prefix, l = {}) {
|
|
1264
|
+
return `<div class="limits">
|
|
1265
|
+
<div class="field"><label for="${prefix}-usd">Dollars</label><input id="${prefix}-usd" type="number" min="0" step="0.5" placeholder="no limit" value="${l?.usd ?? ""}" /></div>
|
|
1266
|
+
<div class="field"><label for="${prefix}-tokens">Tokens</label><input id="${prefix}-tokens" type="number" min="0" step="10000" placeholder="no limit" value="${l?.tokens ?? ""}" /></div>
|
|
1267
|
+
<div class="field"><label for="${prefix}-minutes">Minutes</label><input id="${prefix}-minutes" type="number" min="0" step="5" placeholder="no limit" value="${l?.minutes ?? ""}" /></div></div>`
|
|
1268
|
+
}
|
|
1269
|
+
function readLimits(prefix) {
|
|
1270
|
+
const out = {}
|
|
1271
|
+
for (const k of ["usd", "tokens", "minutes"]) { const v = Number($(`#${prefix}-${k}`)?.value); if (v > 0) out[k] = v }
|
|
1272
|
+
return Object.keys(out).length ? out : null
|
|
1273
|
+
}
|
|
1274
|
+
const dLimits = wireDialog("#d-limits", "#lm-cancel")
|
|
1275
|
+
function openLimits(pid) {
|
|
1276
|
+
const p = (state.projects ?? []).find((x) => x.id === pid)
|
|
1277
|
+
if (!p?.budget) return
|
|
1278
|
+
dLimits.dataset.p = pid
|
|
1279
|
+
const u = p.budget.used
|
|
1280
|
+
$("#lm-used").textContent = `Used so far: ${fmt.money(u.usd)} · ${fmt.n(u.tokens)} tokens · ${Math.round(u.minutes)} minutes`
|
|
1281
|
+
$("#lm-body").innerHTML = limitInputs("lm", p.budget.limits ?? {})
|
|
1282
|
+
$("#lm-go").textContent = p.budget.paused ? "Save and resume" : "Save"
|
|
1283
|
+
$("#lm-err").textContent = ""
|
|
1284
|
+
dLimits.showModal()
|
|
1285
|
+
}
|
|
1286
|
+
$("#lm-go").onclick = async () => {
|
|
1287
|
+
try {
|
|
1288
|
+
await api(`/api/projects/${encodeURIComponent(dLimits.dataset.p)}/budget`, { method: "POST", body: { action: "continue", limits: readLimits("lm") } })
|
|
1289
|
+
dLimits.close()
|
|
1290
|
+
} catch (e) { $("#lm-err").textContent = e.message }
|
|
1291
|
+
}
|
|
1292
|
+
function budgetAskBlock(t) {
|
|
1293
|
+
const b = t.budget
|
|
1294
|
+
const who = sessionById(t.assignee)
|
|
1295
|
+
if (b.kind === "rounds") return `<div class="ask budget-ask" data-ask="${esc(t.id)}">
|
|
1296
|
+
<div class="askh">@${esc(who?.handle ?? "its bot")} wants to send <b>${esc(b.to)}</b> back again, but ${esc(b.why)}. <b>${esc(t.title)}</b> is waiting for you.</div>
|
|
1297
|
+
<div class="askf" style="flex-wrap:wrap"><span class="note">Read the last fix in the chat. One more round sends it back; Accept makes the checker finish and list what still fails.</span>
|
|
1298
|
+
${who ? `<button class="sm" data-bopen>Open @${esc(who.handle)}</button>` : ""}
|
|
1299
|
+
<button class="sm danger" data-bstop>Stop this card</button>
|
|
1300
|
+
<button class="sm" data-baccept>Accept as is</button>
|
|
1301
|
+
<button class="primary sm" data-bgo>One more round</button></div>
|
|
1302
|
+
</div>`
|
|
1303
|
+
if (b.kind === "loop") return `<div class="ask budget-ask" data-ask="${esc(t.id)}">
|
|
1304
|
+
<div class="askh">@${esc(who?.handle ?? "its bot")} looks stuck on <b>${esc(t.title)}</b>: ${esc(b.why ?? "")}. It is stopped.</div>
|
|
1305
|
+
<div class="askf" style="flex-wrap:wrap"><span class="note">Continue tells it to try another way; or open its chat and steer it.</span>
|
|
1306
|
+
${who ? `<button class="sm" data-bopen>Open @${esc(who.handle)}</button>` : ""}
|
|
1307
|
+
<button class="sm danger" data-bstop>Stop this card</button>
|
|
1308
|
+
<button class="primary sm" data-bgo>Continue</button></div>
|
|
1309
|
+
</div>`
|
|
1310
|
+
const f = LIMIT_KINDS.find(([k]) => k === b.kind)[2]
|
|
1311
|
+
return `<div class="ask budget-ask" data-ask="${esc(t.id)}">
|
|
1312
|
+
<div class="askh"><b>${esc(t.title)}</b> reached its ${b.kind === "usd" ? "dollar" : b.kind === "tokens" ? "token" : "time"} limit — ${f(b.used)} of ${f(b.limit)}. @${esc(who?.handle ?? "its bot")} is stopped.</div>
|
|
1313
|
+
<div class="askf" style="flex-wrap:wrap"><span class="note">Continue gives it half as much again; or set exact limits.</span>
|
|
1314
|
+
<button class="sm" data-bset>Set limits…</button>
|
|
1315
|
+
<button class="sm danger" data-bstop>Stop this card</button>
|
|
1316
|
+
<button class="primary sm" data-bgo>Continue (+50%)</button></div>
|
|
1317
|
+
<div data-bform hidden>${limitInputs("bl-" + t.id.replace(/\W/g, ""), t.limits)}<div class="askf"><span class="note">New limits for this card.</span><button class="primary sm" data-bsave>Save and continue</button></div></div>
|
|
1318
|
+
</div>`
|
|
1319
|
+
}
|
|
1320
|
+
function wireBudgetAsk(root, t, done) {
|
|
1321
|
+
const box = $(`[data-ask="${CSS.escape(t.id)}"]`, root)
|
|
1322
|
+
if (!box) return
|
|
1323
|
+
const send = async (body) => { try { await api(`/api/tasks/${encodeURIComponent(t.id)}/budget`, { method: "POST", body }); done() } catch (e) { alert(e.message) } }
|
|
1324
|
+
$("[data-bgo]", box).onclick = () => send({ action: "continue" })
|
|
1325
|
+
$("[data-bstop]", box).onclick = () => confirm("Stop this card? The stage after it goes on without it.") && send({ action: "stop" })
|
|
1326
|
+
if ($("[data-baccept]", box)) $("[data-baccept]", box).onclick = () => send({ action: "accept" })
|
|
1327
|
+
if ($("[data-bopen]", box)) $("[data-bopen]", box).onclick = () => openSession(t.assignee, { taskId: t.id })
|
|
1328
|
+
if (!$("[data-bset]", box)) return
|
|
1329
|
+
$("[data-bset]", box).onclick = () => { $("[data-bform]", box).hidden = false }
|
|
1330
|
+
$("[data-bsave]", box).onclick = () => send({ action: "continue", limits: readLimits("bl-" + t.id.replace(/\W/g, "")) })
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
// --- the farm: the same board, as a place. Cards are crops that move from the
|
|
1334
|
+
// shed to the field to the silo; bots walk to the crop they are working on,
|
|
1335
|
+
// wait at the barn when they need you, and rest at the farmhouse.
|
|
1336
|
+
const farmState = { built: false, crops: new Map(), bots: new Map(), timer: null, observer: null, pid: null }
|
|
1337
|
+
const FARM_ZONES = [
|
|
1338
|
+
{ id: "house", label: "Farmhouse", icon: "🏠", x: .012, w: .125 },
|
|
1339
|
+
{ id: "shed", label: "To do", icon: "🌱", x: .147, w: .165 },
|
|
1340
|
+
{ id: "field", label: "Doing", icon: "🌾", x: .322, w: .335 },
|
|
1341
|
+
{ id: "barn", label: "Needs you", icon: "🔔", x: .667, w: .145 },
|
|
1342
|
+
{ id: "silo", label: "Done", icon: "🌽", x: .822, w: .166 },
|
|
1343
|
+
]
|
|
1344
|
+
const GROUND = [0.27, 0.97]
|
|
1345
|
+
const cropZone = (t) => ({ todo: "shed", doing: "field", needs: "barn", done: "silo" })[colOf(t)]
|
|
1346
|
+
function cropGlyph(t) {
|
|
1347
|
+
if (t.children) return "🚜"
|
|
1348
|
+
if (t.status === "blocked") return "🌰"
|
|
1349
|
+
const c = colOf(t)
|
|
1350
|
+
if (c === "needs") return t.budget ? "⏳" : t.kind === "question" ? "❓" : "🔔"
|
|
1351
|
+
return { todo: "🌱", doing: "🌾", done: "🌽" }[c]
|
|
1352
|
+
}
|
|
1353
|
+
function cropGrowth(t, tasks) {
|
|
1354
|
+
if (t.status === "done") return 1
|
|
1355
|
+
if (t.children) {
|
|
1356
|
+
const kids = t.children.map((id) => tasks.find((x) => x.id === id)).filter(Boolean)
|
|
1357
|
+
return kids.length ? kids.filter((k) => ["done", "cancelled"].includes(k.status)).length / kids.length : 0
|
|
1358
|
+
}
|
|
1359
|
+
if (t.status !== "active") return 0
|
|
1360
|
+
if (t.limits) return Math.min(1, usageFrac(t))
|
|
1361
|
+
return Math.min(.95, (Date.now() - (t.startedAt ?? Date.now())) / (20 * 60_000))
|
|
1362
|
+
}
|
|
1363
|
+
function renderFarm(p, tasks, team) {
|
|
1364
|
+
const farm = $("#w-farm")
|
|
1365
|
+
if (!farm || farm.hidden) return
|
|
1366
|
+
if (!farmState.built || farmState.pid !== p.id) {
|
|
1367
|
+
farm.innerHTML = `<div class="sun"></div><div class="cloud" style="animation-delay:-12s"></div><div class="cloud" style="top:52px;animation-delay:-40s;opacity:.7"></div>
|
|
1368
|
+
${FARM_ZONES.map((z) => `<div class="zone ${z.id}" data-z="${z.id}"><span class="zl"><i>${z.icon}</i>${z.label} <b data-n></b></span></div>`).join("")}
|
|
1369
|
+
<div class="legend">Bots walk to the card they are working on · click a bot or a crop to open it</div>`
|
|
1370
|
+
farmState.crops.clear()
|
|
1371
|
+
farmState.bots.clear()
|
|
1372
|
+
farmState.built = true
|
|
1373
|
+
farmState.pid = p.id
|
|
1374
|
+
farmState.observer?.disconnect()
|
|
1375
|
+
farmState.observer = new ResizeObserver(() => { const q = (state.projects ?? []).find((x) => x.id === farmState.pid); if (q && view() === "farm") renderFarm(q, tasksOf(q), teamOf(q)) })
|
|
1376
|
+
farmState.observer.observe(farm)
|
|
1377
|
+
clearInterval(farmState.timer)
|
|
1378
|
+
farmState.timer = setInterval(farmSparks, 650)
|
|
1379
|
+
}
|
|
1380
|
+
const W = farm.clientWidth, H = farm.clientHeight
|
|
1381
|
+
if (!W || !H) return
|
|
1382
|
+
const rect = {}
|
|
1383
|
+
for (const z of FARM_ZONES) {
|
|
1384
|
+
const r = { x: z.x * W, y: GROUND[0] * H, w: z.w * W, h: (GROUND[1] - GROUND[0]) * H }
|
|
1385
|
+
rect[z.id] = r
|
|
1386
|
+
const el = $(`[data-z="${z.id}"]`, farm)
|
|
1387
|
+
Object.assign(el.style, { left: r.x + "px", top: r.y + "px", width: r.w + "px", height: r.h + "px" })
|
|
1388
|
+
}
|
|
1389
|
+
// crops
|
|
1390
|
+
const byZone = { shed: [], field: [], barn: [], silo: [] }
|
|
1391
|
+
for (const t of tasks) byZone[cropZone(t)]?.push(t)
|
|
1392
|
+
const place = new Map()
|
|
1393
|
+
for (const [zid, list] of Object.entries(byZone)) {
|
|
1394
|
+
const r = rect[zid]
|
|
1395
|
+
$(`[data-z="${zid}"] [data-n]`, farm).textContent = list.length ? `(${list.length})` : ""
|
|
1396
|
+
// In the field each crop has room below it for the bots working it.
|
|
1397
|
+
const field = zid === "field"
|
|
1398
|
+
const cw = field ? 84 : 62
|
|
1399
|
+
const cols = Math.max(1, Math.floor((r.w - 12) / cw))
|
|
1400
|
+
const rows = Math.ceil(list.length / cols)
|
|
1401
|
+
const ch = Math.min(field ? 132 : 66, (r.h - 30) / Math.max(1, rows))
|
|
1402
|
+
const gap = cols > 1 ? Math.min(cw + 10, (r.w - 12 - 58) / (cols - 1)) : 0
|
|
1403
|
+
list.forEach((t, i) => place.set(t.id, { x: r.x + 6 + (i % cols) * gap, y: r.y + 28 + Math.floor(i / cols) * ch }))
|
|
1404
|
+
}
|
|
1405
|
+
const live = new Set()
|
|
1406
|
+
for (const t of tasks) {
|
|
1407
|
+
const pos = place.get(t.id)
|
|
1408
|
+
if (!pos) continue
|
|
1409
|
+
live.add(t.id)
|
|
1410
|
+
let el = farmState.crops.get(t.id)
|
|
1411
|
+
if (!el) {
|
|
1412
|
+
el = document.createElement("div")
|
|
1413
|
+
el.className = "crop"
|
|
1414
|
+
el.onclick = () => openTask(t.id)
|
|
1415
|
+
farm.appendChild(el)
|
|
1416
|
+
farmState.crops.set(t.id, el)
|
|
1417
|
+
el.style.left = pos.x + "px"; el.style.top = (pos.y - 20) + "px"; el.style.opacity = "0"
|
|
1418
|
+
requestAnimationFrame(() => { el.style.opacity = "1" })
|
|
1419
|
+
}
|
|
1420
|
+
const g = cropGrowth(t, tasks)
|
|
1421
|
+
el.className = `crop ${colOf(t) === "doing" ? "active" : ""}`
|
|
1422
|
+
el.title = `${t.stage ? t.stage + " · " : ""}${t.title} — ${t.children ? "split into " + t.children.length : cardStatus(t, tasks)}`
|
|
1423
|
+
el.innerHTML = `<span class="g">${cropGlyph(t)}</span><span class="t">${esc(t.item ? `${t.item.index}/${t.item.total} ` : "")}${esc(t.title)}</span>${colOf(t) === "doing" ? `<span class="grow"><i style="width:${Math.round(g * 100)}%"></i></span>` : ""}`
|
|
1424
|
+
el.style.left = pos.x + "px"
|
|
1425
|
+
el.style.top = pos.y + "px"
|
|
1426
|
+
}
|
|
1427
|
+
for (const [id, el] of farmState.crops) if (!live.has(id)) { el.style.opacity = "0"; setTimeout(() => el.remove(), 400); farmState.crops.delete(id) }
|
|
1428
|
+
// bots
|
|
1429
|
+
const houseSlots = [], barnSlots = []
|
|
1430
|
+
const where = (s) => {
|
|
1431
|
+
const card = tasks.find((t) => t.assignee === s.id && t.status === "active")
|
|
1432
|
+
if (card && place.get(card.id) && s.status !== "waiting") return { kind: "crop", id: card.id }
|
|
1433
|
+
if (s.status === "waiting" || s.status === "error") return { kind: "barn" }
|
|
1434
|
+
return { kind: "house" }
|
|
1435
|
+
}
|
|
1436
|
+
const atCrop = new Map()
|
|
1437
|
+
const liveBots = new Set()
|
|
1438
|
+
for (const s of team) {
|
|
1439
|
+
liveBots.add(s.id)
|
|
1440
|
+
const w = where(s)
|
|
1441
|
+
let x, y
|
|
1442
|
+
if (w.kind === "crop") {
|
|
1443
|
+
const c = place.get(w.id)
|
|
1444
|
+
const n = atCrop.get(w.id) ?? 0
|
|
1445
|
+
atCrop.set(w.id, n + 1)
|
|
1446
|
+
x = c.x + 6 + n * 24; y = c.y + 62 + n * 4
|
|
1447
|
+
} else {
|
|
1448
|
+
const list = w.kind === "barn" ? barnSlots : houseSlots
|
|
1449
|
+
const r = rect[w.kind === "barn" ? "barn" : "house"]
|
|
1450
|
+
const i = list.length
|
|
1451
|
+
list.push(s.id)
|
|
1452
|
+
const per = Math.max(1, Math.floor((r.w - 10) / 54))
|
|
1453
|
+
x = r.x + 8 + (i % per) * 54; y = r.y + 40 + Math.floor(i / per) * 60
|
|
1454
|
+
}
|
|
1455
|
+
let el = farmState.bots.get(s.id)
|
|
1456
|
+
if (!el) {
|
|
1457
|
+
el = document.createElement("div")
|
|
1458
|
+
el.className = "fbot"
|
|
1459
|
+
el.innerHTML = `<img alt="" /><span class="hat"></span><span class="nm"></span><span class="say" hidden></span>`
|
|
1460
|
+
el.onclick = () => openSession(s.id)
|
|
1461
|
+
farm.appendChild(el)
|
|
1462
|
+
farmState.bots.set(s.id, el)
|
|
1463
|
+
el.style.left = (rect.house.x + 8) + "px"; el.style.top = (rect.house.y + 34) + "px"
|
|
1464
|
+
el.dataset.x = el.style.left; el.dataset.y = el.style.top
|
|
1465
|
+
}
|
|
1466
|
+
$("img", el).src = avatarUrl(s)
|
|
1467
|
+
$(".nm", el).textContent = "@" + s.handle
|
|
1468
|
+
const lv = levelOfBot(s)
|
|
1469
|
+
$(".hat", el).textContent = lv?.hat ?? ""
|
|
1470
|
+
el.style.setProperty("--fg", s.color?.fg ?? "#fff")
|
|
1471
|
+
const say = s.status === "busy" ? (s.currentTool ? s.currentTool.name.replace(/^[\w-]+?_(?=\w)/, "") : "…") : s.status === "waiting" ? "needs you" : s.status === "error" ? "⚠" : ""
|
|
1472
|
+
const sayEl = $(".say", el)
|
|
1473
|
+
sayEl.hidden = !say
|
|
1474
|
+
sayEl.textContent = say
|
|
1475
|
+
const moved = el.dataset.x !== x + "px" || el.dataset.y !== y + "px"
|
|
1476
|
+
el.className = `fbot ${esc(s.status)} ${(lv?.level ?? 0) >= 6 ? "gold" : ""} ${moved ? "walking" : el.classList.contains("walking") ? "walking" : ""}`
|
|
1477
|
+
if (moved) {
|
|
1478
|
+
el.dataset.x = x + "px"; el.dataset.y = y + "px"
|
|
1479
|
+
el.style.left = x + "px"; el.style.top = y + "px"
|
|
1480
|
+
clearTimeout(el._walk)
|
|
1481
|
+
el._walk = setTimeout(() => el.classList.remove("walking"), 1600)
|
|
1482
|
+
}
|
|
1483
|
+
el.title = `@${s.handle} · ${s.personaTitle ?? s.persona ?? ""} — ${botDoing(s)}`
|
|
1484
|
+
}
|
|
1485
|
+
for (const [id, el] of farmState.bots) if (!liveBots.has(id)) { el.remove(); farmState.bots.delete(id) }
|
|
1486
|
+
}
|
|
1487
|
+
/** Busy bots throw off sparks: tokens being spent, roughly. */
|
|
1488
|
+
function farmSparks() {
|
|
1489
|
+
const farm = $("#w-farm")
|
|
1490
|
+
if (!farm || farm.hidden || document.visibilityState !== "visible" || matchMedia("(prefers-reduced-motion: reduce)").matches) return
|
|
1491
|
+
for (const el of farm.querySelectorAll(".fbot.busy")) {
|
|
1492
|
+
const sp = document.createElement("span")
|
|
1493
|
+
sp.className = "spark"
|
|
1494
|
+
sp.style.left = (parseFloat(el.style.left) + 20 + Math.random() * 10) + "px"
|
|
1495
|
+
sp.style.top = (parseFloat(el.style.top) + 4) + "px"
|
|
1496
|
+
sp.style.setProperty("--dx", `${Math.round(Math.random() * 30 - 15)}px`)
|
|
1497
|
+
farm.appendChild(sp)
|
|
1498
|
+
setTimeout(() => sp.remove(), 1500)
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
// --- levels: XP per kind of bot per workspace (from the server's farmhands)
|
|
1503
|
+
function levelOfBot(s) {
|
|
1504
|
+
if (!s?.persona) return null
|
|
1505
|
+
const p = (state.projects ?? []).find((x) => x.id === s.project)
|
|
1506
|
+
const wsId = p?.workspaceId ?? ui.ws
|
|
1507
|
+
const l = (state.levels?.[wsId] ?? []).find((x) => x.persona === s.persona)
|
|
1508
|
+
return l && l.xp > 0 ? l : null
|
|
1509
|
+
}
|
|
1510
|
+
const shortModel = (m) => String(m ?? "").split("/").pop().replace(/^(us|eu|apac|global)\./, "").replace(/^anthropic\./, "").replace(/^claude-/, "").replace(/-\d{8}.*$/, "")
|
|
1511
|
+
|
|
1512
|
+
// --- PR packet: written from the handoffs and git; copy it, or open the PR with gh
|
|
1513
|
+
async function openPacket(pid) {
|
|
1514
|
+
const el = openDrawer("packet", pid, `<div class="dh"><div><div class="s">PR packet</div><div style="font-size:16px;font-weight:600" id="pk-title">Writing…</div></div>
|
|
1515
|
+
<div class="acts"><button class="sm" id="pk-copy">Copy</button><button class="sm primary" id="pk-open" title="Push the branch and run gh pr create">Open PR…</button><button class="sm ghost" id="pk-close">Close</button></div></div>
|
|
1516
|
+
<div class="kv" id="pk-kv"></div>
|
|
1517
|
+
<div class="body"><pre class="packet" id="pk-body">…</pre></div>`)
|
|
1518
|
+
$("#pk-close", el).onclick = () => closeDrawer()
|
|
1519
|
+
let pk
|
|
1520
|
+
try { pk = await api(`/api/projects/${encodeURIComponent(pid)}/pr`) } catch (e) { $("#pk-body", el).textContent = e.message; return }
|
|
1521
|
+
$("#pk-title", el).textContent = pk.title
|
|
1522
|
+
$("#pk-kv", el).innerHTML = `<span>saved as <b>${esc(fmt.path(pk.path))}</b></span>${pk.base ? `<span>against <b>${esc(pk.base)}</b></span>` : ""}<span>tests found <b>${pk.tests?.length ?? 0}</b></span><span>files <b>${pk.changed?.length ?? 0}</b></span>`
|
|
1523
|
+
$("#pk-body", el).textContent = pk.markdown
|
|
1524
|
+
$("#pk-copy", el).onclick = async () => { await navigator.clipboard.writeText(pk.markdown).catch(() => {}); $("#pk-copy", el).textContent = "Copied" }
|
|
1525
|
+
$("#pk-open", el).onclick = async () => {
|
|
1526
|
+
if (!confirm(`Push ${pk.branch} and open a pull request with this description (gh pr create)?`)) return
|
|
1527
|
+
$("#pk-open", el).disabled = true
|
|
1528
|
+
try { const out = await api(`/api/projects/${encodeURIComponent(pid)}/pr`, { method: "POST", body: {} }); $("#pk-kv", el).insertAdjacentHTML("beforeend", `<span>opened <b><a href="${esc(out.url)}" target="_blank" rel="noopener">${esc(out.url)}</a></b></span>`) }
|
|
1529
|
+
catch (e) { alert(e.message) }
|
|
1530
|
+
$("#pk-open", el).disabled = false
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
// --- replays: a workstream's timeline, scrubbable; saved as one file, opened anywhere
|
|
1535
|
+
let rp = null
|
|
1536
|
+
async function renderReplay(key) {
|
|
1537
|
+
if (mainKey === key && rp) return
|
|
1538
|
+
mainKey = key
|
|
1539
|
+
const [src, ...rest] = String(ui.id).split(":")
|
|
1540
|
+
const id = rest.join(":")
|
|
1541
|
+
$("#main").innerHTML = `<div class="page"><div class="note">Loading the replay…</div></div>`
|
|
1542
|
+
let doc
|
|
1543
|
+
try {
|
|
1544
|
+
doc = src === "p" ? await (await fetch(`/api/projects/${encodeURIComponent(id)}/replay`)).json() : await api(`/api/replays/${encodeURIComponent(id)}`)
|
|
1545
|
+
if (doc.error) throw new Error(doc.error)
|
|
1546
|
+
} catch (e) { $("#main").innerHTML = `<div class="page"><div class="err">${esc(e.message)}</div></div>`; return }
|
|
1547
|
+
rp = { doc, t: doc.start, playing: false, speed: 60, timer: null, src, id }
|
|
1548
|
+
const len = Math.max(1, doc.end - doc.start)
|
|
1549
|
+
$("#main").innerHTML = `
|
|
1550
|
+
<div class="whead"><div class="row1"><span class="dot done"></span><h1>${esc(doc.project.name)}</h1><span class="chip">replay</span>${doc.project.branch ? `<span class="chip">${esc(doc.project.branch)}</span>` : ""}
|
|
1551
|
+
<div class="acts">${src === "p" ? `<a class="sm ghost btnlink" href="/api/projects/${encodeURIComponent(id)}/replay?download=1" download>Save replay file</a><button class="sm ghost" id="rp-back">Back to the workstream</button>` : `<span class="note">imported ${esc(doc.exportedAt?.slice(0, 10) ?? "")}</span>`}</div></div>
|
|
1552
|
+
<div class="scrub">
|
|
1553
|
+
<button class="sm primary" id="rp-play">▶ Play</button>
|
|
1554
|
+
<select id="rp-speed" title="Speed">${[1, 10, 60, 300, 1800].map((x) => `<option value="${x}" ${x === 60 ? "selected" : ""}>${x}×</option>`).join("")}</select>
|
|
1555
|
+
<button class="sm ghost" id="rp-next" title="Jump to the next thing that happened">Next ›</button>
|
|
1556
|
+
<input type="range" id="rp-range" min="0" max="${len}" value="0" step="1000" />
|
|
1557
|
+
<span class="mono note" id="rp-time"></span>
|
|
1558
|
+
</div></div>
|
|
1559
|
+
<div class="wlayout">
|
|
1560
|
+
<aside class="rail"><div class="rh">Team</div><div class="list" id="rp-team"></div></aside>
|
|
1561
|
+
<div class="wmain"><div class="wbody"><div class="farm" id="w-farm"></div><div class="splitter"></div>
|
|
1562
|
+
<section class="chat"><div class="ch"><b>Chat</b><span class="note" id="rp-count"></span></div><div class="log" id="rp-log"></div></section></div></div>
|
|
1563
|
+
</div>`
|
|
1564
|
+
farmState.built = false
|
|
1565
|
+
$("#rp-back")?.addEventListener("click", () => go("stream", id))
|
|
1566
|
+
$("#rp-range").oninput = (e) => { rp.t = doc.start + Number(e.target.value); paintReplay() }
|
|
1567
|
+
$("#rp-speed").onchange = (e) => { rp.speed = Number(e.target.value) }
|
|
1568
|
+
$("#rp-next").onclick = () => { const n = doc.events.find((ev) => ev.t > rp.t + 500); if (n) { rp.t = n.t; paintReplay() } }
|
|
1569
|
+
$("#rp-play").onclick = () => {
|
|
1570
|
+
rp.playing = !rp.playing
|
|
1571
|
+
$("#rp-play").textContent = rp.playing ? "❚❚ Pause" : "▶ Play"
|
|
1572
|
+
clearInterval(rp.timer)
|
|
1573
|
+
if (rp.playing) rp.timer = setInterval(() => {
|
|
1574
|
+
if (!document.body.contains($("#rp-range"))) return clearInterval(rp.timer)
|
|
1575
|
+
rp.t = Math.min(doc.end, rp.t + 250 * rp.speed)
|
|
1576
|
+
if (rp.t >= doc.end) { rp.playing = false; clearInterval(rp.timer); $("#rp-play").textContent = "▶ Play" }
|
|
1577
|
+
paintReplay()
|
|
1578
|
+
}, 250)
|
|
1579
|
+
}
|
|
1580
|
+
paintReplay()
|
|
1581
|
+
}
|
|
1582
|
+
/** The workstream as it was at rp.t: fold the events up to then. */
|
|
1583
|
+
function replayStateAt(doc, t) {
|
|
1584
|
+
const bots = new Map(), tasks = new Map(), msgs = []
|
|
1585
|
+
let harvest = null
|
|
1586
|
+
for (const ev of doc.events) {
|
|
1587
|
+
if (ev.t > t) break
|
|
1588
|
+
if (ev.k === "bot") bots.set(ev.bot.id, { ...(bots.get(ev.bot.id) ?? {}), ...ev.bot, currentTool: ev.bot.tool ? { name: ev.bot.tool } : null, project: "replay" })
|
|
1589
|
+
else if (ev.k === "task") tasks.set(ev.task.id, { ...ev.task, projectId: "replay" })
|
|
1590
|
+
else if (ev.k === "msg") msgs.push({ ...ev.msg, at: ev.t })
|
|
1591
|
+
else if (ev.k === "note") msgs.push({ id: "n" + ev.t, sys: true, text: ev.text, level: ev.level, at: ev.t })
|
|
1592
|
+
else if (ev.k === "harvest") harvest = ev.harvest
|
|
1593
|
+
}
|
|
1594
|
+
const known = new Map(doc.team.map((s) => [s.id, s]))
|
|
1595
|
+
const team = [...bots.values()].map((b) => ({ ...known.get(b.id), ...b }))
|
|
1596
|
+
return { team, tasks: [...tasks.values()].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)), msgs, harvest }
|
|
1597
|
+
}
|
|
1598
|
+
function paintReplay() {
|
|
1599
|
+
if (!rp) return
|
|
1600
|
+
const { doc } = rp
|
|
1601
|
+
const st = replayStateAt(doc, rp.t)
|
|
1602
|
+
$("#rp-range").value = String(rp.t - doc.start)
|
|
1603
|
+
$("#rp-time").textContent = `${new Date(rp.t).toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })} · ${fmt.ago(doc.start) ? Math.round((rp.t - doc.start) / 60000) + " min in" : ""}`
|
|
1604
|
+
$("#rp-team").innerHTML = st.team.map((s) => `<div class="rbot ${esc(s.status)}"><span class="ava"><img src="${avatarUrl(s)}" alt="" /><span class="pip"></span></span><div class="who"><span class="top"><span class="h">@${esc(s.handle)}</span>${s.personaTitle ? `<span class="kind">${esc(s.personaTitle)}</span>` : ""}</span><span class="d">${esc(s.status === "busy" ? (s.tool ? "running " + s.tool : "thinking…") : s.status)}</span></div></div>`).join("")
|
|
1605
|
+
renderFarm({ id: "replay:" + rp.id }, st.tasks, st.team)
|
|
1606
|
+
const log = $("#rp-log")
|
|
1607
|
+
$("#rp-count").textContent = `${st.msgs.length} messages`
|
|
1608
|
+
log.innerHTML = st.msgs.slice(-80).map((m) => m.sys
|
|
1609
|
+
? `<div class="sys ${esc(m.level ?? "")}">${fmt.time(m.at)} ${esc(m.text)}</div>`
|
|
1610
|
+
: `<div class="msg ${m.from === "operator" ? "op" : ""}">${m.from === "operator" ? `<span class="av">you</span>` : `<img class="av" src="${avatarUrl({ id: m.fromId ?? m.from, handle: m.from })}" alt="" />`}<div class="c"><div class="m"><b>${m.from === "operator" ? "you" : "@" + esc(m.from)}</b><span>${fmt.time(m.at)}</span></div><div class="b">${esc(m.text)}</div></div></div>`).join("")
|
|
1611
|
+
log.scrollTop = log.scrollHeight
|
|
1612
|
+
if (st.harvest && !rp.celebrated) { rp.celebrated = true; celebrate(st.harvest, { replay: true }) }
|
|
1613
|
+
}
|
|
1614
|
+
async function importReplayFile() {
|
|
1615
|
+
const input = document.createElement("input")
|
|
1616
|
+
input.type = "file"
|
|
1617
|
+
input.accept = ".json,application/json"
|
|
1618
|
+
input.onchange = async () => {
|
|
1619
|
+
const f = input.files?.[0]
|
|
1620
|
+
if (!f) return
|
|
1621
|
+
try {
|
|
1622
|
+
const doc = JSON.parse(await f.text())
|
|
1623
|
+
const out = await api("/api/replays", { method: "POST", body: doc })
|
|
1624
|
+
go("replay", "s:" + out.id)
|
|
1625
|
+
} catch (e) { alert(e.message) }
|
|
1626
|
+
}
|
|
1627
|
+
input.click()
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
// --- harvest: a workstream that finished gets a moment, and a line in the log
|
|
1631
|
+
const seenHarvests = new Set(store.get("harvests", []))
|
|
1632
|
+
function checkHarvests() {
|
|
1633
|
+
for (const p of state.projects ?? []) {
|
|
1634
|
+
const h = p.harvest
|
|
1635
|
+
if (!h || seenHarvests.has(h.id)) continue
|
|
1636
|
+
seenHarvests.add(h.id)
|
|
1637
|
+
store.set("harvests", [...seenHarvests].slice(-200))
|
|
1638
|
+
// Only celebrate what finished while you were here, not old ones on first load.
|
|
1639
|
+
if (Date.now() - h.at < 10 * 60_000 && p.workspaceId === ui.ws) celebrate(h)
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
function celebrate(h, { replay = false } = {}) {
|
|
1643
|
+
document.querySelector(".harvest")?.remove()
|
|
1644
|
+
const crops = ["🌽", "🌾", "🥕", "🍅", "🍎", "🌻", "🥔", "🍓"]
|
|
1645
|
+
const still = matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
1646
|
+
const el = document.createElement("div")
|
|
1647
|
+
el.className = "harvest"
|
|
1648
|
+
el.innerHTML = `${still ? "" : Array.from({ length: 36 }, (_, i) => `<span class="fall" style="left:${Math.random() * 100}%;animation-delay:${(Math.random() * 1.2).toFixed(2)}s;animation-duration:${(2.2 + Math.random() * 1.8).toFixed(2)}s">${crops[i % crops.length]}</span>`).join("")}
|
|
1649
|
+
<div class="hcard" role="dialog" aria-label="Harvest">
|
|
1650
|
+
<div class="hbig">🧺</div>
|
|
1651
|
+
<h2>Harvest${replay ? " (replay)" : ""}: ${esc(h.name)}</h2>
|
|
1652
|
+
<div class="hstats">
|
|
1653
|
+
<div><b>${h.cards}</b><i>cards</i></div>
|
|
1654
|
+
<div><b>${Math.round(h.minutes)}m</b><i>start to finish</i></div>
|
|
1655
|
+
<div><b>${fmt.money(h.usd)}</b><i>${h.budget ? `of ${fmt.money(h.budget)}` : "spent"}</i></div>
|
|
1656
|
+
${h.saved > 0 ? `<div class="good"><b>${fmt.money(h.saved)}</b><i>under budget</i></div>` : ""}
|
|
1657
|
+
<div><b>+${h.xp}</b><i>XP</i></div>
|
|
1658
|
+
</div>
|
|
1659
|
+
${(h.levelUps ?? []).length ? `<div class="lvups">${h.levelUps.map((l) => `<span class="lvup">${l.hat ?? "⭐"} ${esc(l.persona)} reached level ${l.level} — ${esc(l.title)}</span>`).join("")}</div>` : ""}
|
|
1660
|
+
<div class="hacts">${!replay ? `<button class="sm" data-h="packet">PR packet</button><button class="sm" data-h="apply" title="Turn the work into patches and apply them to your real repos, uncommitted">Apply to my repos…</button>` : ""}<button class="primary" data-h="close">Nice</button></div>
|
|
1661
|
+
</div>`
|
|
1662
|
+
document.body.appendChild(el)
|
|
1663
|
+
const close = () => el.remove()
|
|
1664
|
+
el.onclick = (e) => { if (e.target === el) close() }
|
|
1665
|
+
$("[data-h=close]", el).onclick = close
|
|
1666
|
+
$("[data-h=packet]", el)?.addEventListener("click", () => { close(); openPacket(h.project) })
|
|
1667
|
+
$("[data-h=apply]", el)?.addEventListener("click", () => { close(); openApply(h.project) })
|
|
1668
|
+
setTimeout(() => el.classList.add("settled"), 3500)
|
|
1669
|
+
}
|
|
1670
|
+
async function harvestPanel(wsId) {
|
|
1671
|
+
const box = $("#home-harvest")
|
|
1672
|
+
if (!box) return
|
|
1673
|
+
let d
|
|
1674
|
+
try { d = await api(`/api/workspaces/${encodeURIComponent(wsId)}/harvest`) } catch { return }
|
|
1675
|
+
const reps = await api("/api/replays").catch(() => [])
|
|
1676
|
+
const w = d.week
|
|
1677
|
+
box.innerHTML = `<div class="hpanel">
|
|
1678
|
+
<section class="panel"><h3>Harvest log <span class="note">this week</span></h3>
|
|
1679
|
+
<div class="hstats small"><div><b>${w.stories}</b><i>stories</i></div><div><b>${w.cards}</b><i>cards</i></div><div><b>${fmt.money(w.usd)}</b><i>spent</i></div><div class="good"><b>${fmt.money(w.saved)}</b><i>under budget</i></div><div><b>${(w.botMinutes / 60).toFixed(1)}h</b><i>bot work</i></div></div>
|
|
1680
|
+
${d.recent.length ? `<div class="hlist">${d.recent.map((h) => `<div class="hrow"><span>🧺</span><b>${esc(h.name)}</b><span class="note mono">${fmt.ago(h.at)} ago · ${h.cards} cards · ${fmt.money(h.usd)}${h.saved > 0 ? ` · <span style="color:var(--ok)">${fmt.money(h.saved)} under</span>` : ""} · +${h.xp} XP</span>${(h.levelUps ?? []).map((l) => `<span title="${esc(l.persona)} level ${l.level}">${l.hat ?? "⭐"}</span>`).join("")}<button class="sm ghost" data-rp="${esc(h.project)}">Replay</button></div>`).join("")}</div>` : `<div class="note">Nothing harvested yet — finish a workstream.</div>`}
|
|
1681
|
+
</section>
|
|
1682
|
+
<section class="panel"><h3>The farmhands</h3>
|
|
1683
|
+
${d.levels.length ? d.levels.sort((a, b) => b.xp - a.xp).map((l) => `<div class="lrow" title="${esc((l.perks ?? []).map((x) => "• " + x.label).join("\\n"))}"><span class="lhat">${l.hat ?? "🌱"}</span><b>${esc(l.persona)}</b><span class="note">Lv ${l.level} ${esc(l.title)}</span><span class="lbar"><i style="width:${Math.round((l.progress ?? 0) * 100)}%"></i></span><span class="mono note">${l.xp} XP</span></div>`).join("") : `<div class="note">Bots earn XP for clean handoffs: no pauses, under budget, nothing left open.</div>`}
|
|
1684
|
+
</section>
|
|
1685
|
+
<section class="panel"><h3>Replays <button class="sm ghost" id="rp-import" style="float:right">Open a replay file…</button></h3>
|
|
1686
|
+
${reps.length ? reps.slice(0, 8).map((r) => `<div class="hrow"><span>🎞️</span><b>${esc(r.name)}</b><span class="note mono">${r.events} events · ${new Date(r.end ?? 0).toLocaleDateString()}</span><button class="sm ghost" data-rps="${esc(r.id)}">Open</button></div>`).join("") : `<div class="note">Save one from a workstream (Replay → Save replay file) and open it here or on another computer.</div>`}
|
|
1687
|
+
</section></div>`
|
|
1688
|
+
for (const b of $$("[data-rp]", box)) b.onclick = () => go("replay", "p:" + b.dataset.rp)
|
|
1689
|
+
for (const b of $$("[data-rps]", box)) b.onclick = () => go("replay", "s:" + b.dataset.rps)
|
|
1690
|
+
$("#rp-import", box).onclick = importReplayFile
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
function wireSplit() {
|
|
1694
|
+
const body = $(".wbody")
|
|
1695
|
+
const bar = $("#w-split")
|
|
1696
|
+
const set = (pct) => body.style.setProperty("--split", `${Math.min(80, Math.max(15, pct))}%`)
|
|
1697
|
+
set(store.get("split", 44))
|
|
1698
|
+
bar.onpointerdown = (e) => {
|
|
1699
|
+
bar.setPointerCapture(e.pointerId)
|
|
1700
|
+
bar.classList.add("drag")
|
|
1701
|
+
const top = body.getBoundingClientRect().top
|
|
1702
|
+
const h = body.getBoundingClientRect().height
|
|
1703
|
+
bar.onpointermove = (m) => { const pct = ((m.clientY - top) / h) * 100; set(pct); store.set("split", Math.round(Math.min(80, Math.max(15, pct)))) }
|
|
1704
|
+
bar.onpointerup = () => { bar.onpointermove = null; bar.classList.remove("drag") }
|
|
1705
|
+
}
|
|
1706
|
+
bar.ondblclick = () => { set(44); store.set("split", 44) }
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
let dragId = null
|
|
1710
|
+
const seenCards = new Set()
|
|
1711
|
+
function cardHtml(t, tasks) {
|
|
1712
|
+
const who = sessionById(t.assignee)
|
|
1713
|
+
const kids = t.children ? t.children.map((id) => tasks.find((x) => x.id === id)).filter(Boolean) : null
|
|
1714
|
+
const frac = usageFrac(t)
|
|
1715
|
+
const kidDone = kids ? kids.filter((k) => ["done", "cancelled"].includes(k.status)).length : 0
|
|
1716
|
+
const kidRun = kids ? kids.filter((k) => k.status === "active").length : 0
|
|
1717
|
+
const status = kids ? `${kidDone}/${kids.length} pieces done${kidRun ? ` · ${kidRun} in progress` : ""}` : t.budget ? (t.budget.kind === "loop" ? `paused: looks stuck` : t.budget.kind === "rounds" ? `paused: ${t.budget.round} fix rounds` : `paused: ${t.budget.kind === "usd" ? "dollar" : t.budget.kind === "tokens" ? "token" : "time"} limit reached`) : cardStatus(t, tasks)
|
|
1718
|
+
return `<div class="card ${esc(t.status)} ${t.kind === "question" ? "question" : ""} ${kids ? "group" : ""}" draggable="${kids ? "false" : "true"}" data-t="${esc(t.id)}">
|
|
1719
|
+
${t.stage ? `<span class="stage">${esc(t.stage)}${t.item ? ` <span class="part">· piece ${t.item.index}/${t.item.total}</span>` : ""}${t.split && !kids ? ` <span class="part">· splits for up to ${t.split.parallel}</span>` : ""}</span>` : ""}
|
|
1720
|
+
<span class="ttl">${esc(t.title)}</span>
|
|
1721
|
+
${t.difficulty || t.xp || t.round > 1 || t.recheck ? `<span class="tags">${t.round > 1 ? `<span class="round" title="${esc((t.history ?? []).map((h) => `round ${h.round}${h.by ? " by @" + h.by : ""}`).join("\n"))}">↩ round ${t.round}</span>` : ""}${t.recheck ? `<span class="round chk">↻ verify ${t.recheck.round + 1}</span>` : ""}${t.difficulty ? `<span class="diff d-${esc(t.difficulty.level)}" title="${esc(t.difficulty.why)}${t.difficulty.route ? " → " + esc([t.difficulty.route.model?.split("/").pop(), t.difficulty.route.variant].filter(Boolean).join(" · ")) : ""}${t.difficulty.route?.fellBack ? " — fallback: " + esc(t.difficulty.route.fellBack.why) : ""}">${esc(t.difficulty.level)}${t.difficulty.route?.model ? " · " + esc(shortModel(t.difficulty.route.model)) : t.difficulty.route?.variant ? " · " + esc(t.difficulty.route.variant) : ""}${t.difficulty.route?.fellBack ? " ↓" : ""}</span>` : ""}${t.xp ? `<span class="xp" title="${esc(t.xp.reasons.join("\n"))}">+${t.xp.gained} XP</span>` : ""}</span>` : ""}
|
|
1722
|
+
${kids ? `<div class="pieces"><span class="track"><i style="width:${(kidDone / kids.length) * 100}%"></i><i class="run" style="width:${(kidRun / kids.length) * 100}%"></i></span>
|
|
1723
|
+
<span class="avs">${[...new Set(kids.map((k) => k.assignee).filter(Boolean))].map((id) => sessionById(id)).filter(Boolean).map((b) => `<img src="${avatarUrl(b)}" alt="@${esc(b.handle)}" title="@${esc(b.handle)}" />`).join("")}</span></div>` : ""}
|
|
1724
|
+
${t.limits && t.status === "active" && !kids ? `<div class="usage" title="${esc(Object.entries(t.limits).map(([k, v]) => `${k}: ${LIMIT_KINDS.find(([x]) => x === k)[2](t.usage?.[k] ?? 0)} of ${LIMIT_KINDS.find(([x]) => x === k)[2](v)}`).join("\n"))}"><i class="${frac >= 1 ? "over" : ""}" style="width:${Math.min(100, Math.round(frac * 100))}%"></i></div>` : ""}
|
|
1725
|
+
<div class="meta">${who ? `<img src="${avatarUrl(who)}" alt="" />` : ""}
|
|
1726
|
+
<span class="who"><span class="h" ${who?.color ? `style="color:${esc(who.color.fg)}"` : ""}>${who ? "@" + esc(who.handle) : t.owner === "human" ? "you" : kids ? `${kids.length} cards` : esc(t.role ?? "unassigned")}</span>
|
|
1727
|
+
<span class="st">${esc(status)}</span></span></div></div>`
|
|
1728
|
+
}
|
|
1729
|
+
function renderBoard(p, tasks) {
|
|
1730
|
+
const board = $("#w-board")
|
|
1731
|
+
// FLIP: remember where every card was, render, then slide each from there.
|
|
1732
|
+
const before = new Map($$(".card[data-t]", board).map((c) => [c.dataset.t, c.getBoundingClientRect()]))
|
|
1733
|
+
const first = !before.size
|
|
1734
|
+
board.innerHTML = COLS.map((c) => {
|
|
1735
|
+
const cards = tasks.filter((t) => colOf(t) === c.id)
|
|
1736
|
+
return `<div class="col ${c.id}" data-col="${c.id}"><h5>${c.label}<span>${cards.length}</span></h5><div class="cards">
|
|
1737
|
+
${cards.map((t) => cardHtml(t, tasks)).join("")}
|
|
1738
|
+
${c.id === "todo" && p.status !== "archived" ? `<button class="addcard" data-add="1">+ Add a card</button>` : ""}
|
|
1739
|
+
</div></div>`
|
|
1740
|
+
}).join("")
|
|
1741
|
+
const still = !matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
1742
|
+
for (const c of $$(".card[data-t]", board)) {
|
|
1743
|
+
const was = before.get(c.dataset.t)
|
|
1744
|
+
if (!still) continue
|
|
1745
|
+
if (was) {
|
|
1746
|
+
const now = c.getBoundingClientRect()
|
|
1747
|
+
const dx = was.left - now.left, dy = was.top - now.top
|
|
1748
|
+
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) {
|
|
1749
|
+
c.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: "none" }], { duration: 450, easing: "cubic-bezier(.2,.8,.2,1)" })
|
|
1750
|
+
if (Math.abs(dx) > 40) { c.classList.add("flash"); setTimeout(() => c.classList.remove("flash"), 900) }
|
|
1751
|
+
}
|
|
1752
|
+
} else if (!first && !seenCards.has(c.dataset.t)) c.classList.add("enter")
|
|
1753
|
+
seenCards.add(c.dataset.t)
|
|
1754
|
+
}
|
|
1755
|
+
for (const c of $$(".card", board)) {
|
|
1756
|
+
c.onclick = () => openTask(c.dataset.t)
|
|
1757
|
+
c.ondragstart = (e) => { dragId = c.dataset.t; c.classList.add("dragging"); e.dataTransfer.effectAllowed = "move" }
|
|
1758
|
+
c.ondragend = () => { dragId = null; c.classList.remove("dragging") }
|
|
1759
|
+
}
|
|
1760
|
+
const add = $("[data-add]", board)
|
|
1761
|
+
if (add) add.onclick = () => openNewCard(p.id)
|
|
1762
|
+
for (const col of $$(".col", board)) {
|
|
1763
|
+
if (col.dataset.col === "doing") continue // work starts when a bot picks it up, not by dragging
|
|
1764
|
+
col.ondragover = (e) => { if (dragId) { e.preventDefault(); col.classList.add("over") } }
|
|
1765
|
+
col.ondragleave = () => col.classList.remove("over")
|
|
1766
|
+
col.ondrop = async (e) => {
|
|
1767
|
+
e.preventDefault()
|
|
1768
|
+
col.classList.remove("over")
|
|
1769
|
+
const t = (state.tasks ?? []).find((x) => x.id === dragId)
|
|
1770
|
+
if (!t) return
|
|
1771
|
+
if (col.dataset.col === "todo" && canSendBack(t)) { await sendBackPrompt(t); return }
|
|
1772
|
+
const status = col.dataset.col === "done" ? "done" : col.dataset.col === "needs" ? "review" : t.dependsOn?.length && t.status === "blocked" ? "blocked" : t.assignee || t.role ? "queued" : "backlog"
|
|
1773
|
+
if (status === t.status) return
|
|
1774
|
+
await api(`/api/tasks/${encodeURIComponent(t.id)}`, { method: "POST", body: { status, queuedAt: Date.now() } }).catch((err) => alert(err.message))
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
const expanded = new Set() // long chat messages opened in full
|
|
1780
|
+
const seenMsgs = new Set() // messages already shown, so only new ones animate in
|
|
1781
|
+
function renderLog(p, room, events, team, pending = []) {
|
|
1782
|
+
const log = $("#w-log")
|
|
1783
|
+
const atBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 60
|
|
1784
|
+
$("#w-room").textContent = room?.name ? "#" + room.name.replace(/^#/, "") : "#" + p.id
|
|
1785
|
+
$("#w-members").textContent = room ? `${room.members.length} members${room.muted ? " · muted" : ""}` : ""
|
|
1786
|
+
const handles = new Set(team.map((s) => s.handle))
|
|
1787
|
+
const items = [
|
|
1788
|
+
...(room?.messages ?? []).map((m) => ({ at: m.at, m })),
|
|
1789
|
+
// Only the moments that change what the team is doing; the rest is in the activity of each bot.
|
|
1790
|
+
...events.filter((e) => e.level === "error" || /picked up|went idle|is stuck|needs you|needs a permission|aborted|reminded/.test(e.text)).slice(0, 30).map((e) => ({ at: e.at, e })),
|
|
1791
|
+
].sort((a, b) => a.at - b.at)
|
|
1792
|
+
const byHandle = Object.fromEntries(team.map((s) => [s.handle, s]))
|
|
1793
|
+
const linkify = (text) => esc(text).replace(/@([a-z]+-[a-z]+)/g, (m, h) => {
|
|
1794
|
+
const s = byHandle[h]
|
|
1795
|
+
return s ? `<span class="at" style="--atc:${esc(s.color?.fg ?? "")}">@${h}</span>` : m
|
|
1796
|
+
})
|
|
1797
|
+
let prev = null
|
|
1798
|
+
const logPrimed = seenMsgs.size > 0
|
|
1799
|
+
log.innerHTML = items.length
|
|
1800
|
+
? items.map(({ m, e }) => {
|
|
1801
|
+
if (e) { prev = null; return `<div class="sys ${esc(e.level)}">${fmt.time(e.at)} ${esc(e.text)}</div>` }
|
|
1802
|
+
const op = m.from === "operator"
|
|
1803
|
+
const who = team.find((s) => s.id === m.fromId) ?? byHandle[m.from]
|
|
1804
|
+
// Consecutive messages from one author within a few minutes read as one turn.
|
|
1805
|
+
const cont = prev && prev.from === m.from && m.at - prev.at < 180_000
|
|
1806
|
+
prev = m
|
|
1807
|
+
const style = !op && who?.color ? `style="--fg:${esc(who.color.fg)};--bg:${esc(who.color.bg)}"` : ""
|
|
1808
|
+
const finished = /^Finished (\S+): /.exec(m.text)
|
|
1809
|
+
const text = finished ? m.text.slice(finished[0].length) : m.text
|
|
1810
|
+
const isNew = logPrimed && !seenMsgs.has(m.id)
|
|
1811
|
+
seenMsgs.add(m.id)
|
|
1812
|
+
if (m.meta?.kind === "card") { prev = null; return cardEventHtml(m, who, text, isNew) }
|
|
1813
|
+
return `<div class="msg ${op ? "op" : ""} ${cont ? "cont" : ""} ${isNew ? "enter" : ""}" ${style}>
|
|
1814
|
+
${op ? `<span class="av">you</span>` : `<img class="av" src="${avatarUrl({ id: m.fromId ?? m.from, handle: who?.handle ?? m.from })}" alt="" />`}
|
|
1815
|
+
<div class="c"><div class="m"><b>${op ? "you" : "@" + esc(m.from)}</b>${!op && who ? `<span class="role">${esc(who.role ?? who.persona ?? "")}</span>` : ""}<span>${fmt.time(m.at)}</span></div>
|
|
1816
|
+
<div class="b ${text.length > 700 && !expanded.has(m.id) ? "long" : ""}">${finished ? `<span class="tag">handed off ${esc(finished[1])}</span>\n` : ""}${linkify(text)}</div>
|
|
1817
|
+
${text.length > 700 ? `<button class="more" data-more="${esc(m.id)}">${expanded.has(m.id) ? "show less" : `show all ${fmt.n(text.length)} characters`}</button>` : ""}</div></div>`
|
|
1818
|
+
}).join("")
|
|
1819
|
+
: `<div class="empty">Nothing said yet. What you post here reaches the whole team; @handle reaches one bot.</div>`
|
|
1820
|
+
wireCardEvents(log)
|
|
1821
|
+
for (const b of $$("[data-more]", log)) b.onclick = () => {
|
|
1822
|
+
expanded.has(b.dataset.more) ? expanded.delete(b.dataset.more) : expanded.add(b.dataset.more)
|
|
1823
|
+
streamSig.c = null
|
|
1824
|
+
const keep = log.scrollTop
|
|
1825
|
+
renderStream(mainKey)
|
|
1826
|
+
log.scrollTop = keep
|
|
1827
|
+
}
|
|
1828
|
+
// Questions waiting on you sit at the bottom of the conversation, answerable in place.
|
|
1829
|
+
log.insertAdjacentHTML("beforeend", pending.map((t) => askBlock(t)).join(""))
|
|
1830
|
+
for (const t of pending) wireAsk(log, t)
|
|
1831
|
+
// What opencode itself is waiting on: tool permissions and its own questions.
|
|
1832
|
+
for (const s of team.filter((x) => x.permissions?.length || x.questions?.length)) {
|
|
1833
|
+
log.insertAdjacentHTML("beforeend", botAsksHtml(s))
|
|
1834
|
+
wireBotAsks(log, s)
|
|
1835
|
+
}
|
|
1836
|
+
if (atBottom || !streamSig.scrolled) { log.scrollTop = log.scrollHeight; streamSig.scrolled = true }
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
function wireComposer(p) {
|
|
1840
|
+
const input = $("#w-input")
|
|
1841
|
+
const box = $("#w-mentions")
|
|
1842
|
+
let picks = []
|
|
1843
|
+
let sel = 0
|
|
1844
|
+
const close = () => { box.hidden = true; picks = [] }
|
|
1845
|
+
const show = () => {
|
|
1846
|
+
const before = input.value.slice(0, input.selectionStart)
|
|
1847
|
+
const m = /@([a-z-]*)$/.exec(before)
|
|
1848
|
+
if (!m) return close()
|
|
1849
|
+
picks = teamOf(p).filter((s) => s.handle?.startsWith(m[1]))
|
|
1850
|
+
if (!picks.length) return close()
|
|
1851
|
+
sel = Math.min(sel, picks.length - 1)
|
|
1852
|
+
box.hidden = false
|
|
1853
|
+
box.innerHTML = picks.map((s, i) => `<button data-i="${i}" aria-selected="${i === sel}"><img src="${avatarUrl(s)}" alt="" />@${esc(s.handle)} <span class="note">${esc(s.role ?? s.persona ?? "")}</span></button>`).join("")
|
|
1854
|
+
for (const b of $$("button", box)) b.onmousedown = (e) => { e.preventDefault(); pick(Number(b.dataset.i)) }
|
|
1855
|
+
}
|
|
1856
|
+
const pick = (i) => {
|
|
1857
|
+
const s = picks[i]
|
|
1858
|
+
if (!s) return
|
|
1859
|
+
const pos = input.selectionStart
|
|
1860
|
+
const before = input.value.slice(0, pos).replace(/@([a-z-]*)$/, `@${s.handle} `)
|
|
1861
|
+
input.value = before + input.value.slice(pos)
|
|
1862
|
+
input.selectionStart = input.selectionEnd = before.length
|
|
1863
|
+
close()
|
|
1864
|
+
input.focus()
|
|
1865
|
+
}
|
|
1866
|
+
const send = async () => {
|
|
1867
|
+
const message = input.value.trim()
|
|
1868
|
+
if (!message) return
|
|
1869
|
+
input.value = ""
|
|
1870
|
+
close()
|
|
1871
|
+
await api(`/api/projects/${encodeURIComponent(p.id)}/chat`, { method: "POST", body: { message } }).catch((e) => { alert(e.message); input.value = message })
|
|
1872
|
+
}
|
|
1873
|
+
input.oninput = () => { input.style.height = "auto"; input.style.height = Math.min(160, input.scrollHeight) + "px"; show() }
|
|
1874
|
+
input.onkeydown = (e) => {
|
|
1875
|
+
if (!box.hidden) {
|
|
1876
|
+
if (e.key === "ArrowDown") { e.preventDefault(); sel = (sel + 1) % picks.length; return show() }
|
|
1877
|
+
if (e.key === "ArrowUp") { e.preventDefault(); sel = (sel - 1 + picks.length) % picks.length; return show() }
|
|
1878
|
+
if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); return pick(sel) }
|
|
1879
|
+
if (e.key === "Escape") return close()
|
|
1880
|
+
}
|
|
1881
|
+
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send() }
|
|
1882
|
+
}
|
|
1883
|
+
input.onblur = () => setTimeout(close, 100)
|
|
1884
|
+
$("#w-send").onclick = send
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
// ------------------------------------------------------------------ drawers
|
|
1888
|
+
let drawer = null // { kind, id, render }
|
|
1889
|
+
function openDrawer(kind, id, html) {
|
|
1890
|
+
closeDrawer(true)
|
|
1891
|
+
const scrim = document.createElement("div")
|
|
1892
|
+
scrim.className = "scrim"
|
|
1893
|
+
scrim.id = "scrim"
|
|
1894
|
+
scrim.onclick = () => closeDrawer()
|
|
1895
|
+
const el = document.createElement("section")
|
|
1896
|
+
el.className = "drawer"
|
|
1897
|
+
el.id = "drawer"
|
|
1898
|
+
el.innerHTML = html
|
|
1899
|
+
document.body.append(scrim, el)
|
|
1900
|
+
drawer = { kind, id }
|
|
1901
|
+
return el
|
|
1902
|
+
}
|
|
1903
|
+
function closeDrawer(silent = false) {
|
|
1904
|
+
if (drawer?.kind === "session") wsSend(`unwatch:${drawer.id}`)
|
|
1905
|
+
drawer = null
|
|
1906
|
+
live = { id: null, msgs: null, status: null, currentTool: null, at: 0 }
|
|
1907
|
+
$("#scrim")?.remove()
|
|
1908
|
+
$("#drawer")?.remove()
|
|
1909
|
+
}
|
|
1910
|
+
document.addEventListener("keydown", (e) => { if (e.key === "Escape" && drawer && !document.querySelector("dialog[open]")) closeDrawer() })
|
|
1911
|
+
|
|
1912
|
+
function openTask(id) {
|
|
1913
|
+
const t = (state.tasks ?? []).find((x) => x.id === id)
|
|
1914
|
+
if (!t) return
|
|
1915
|
+
const who = sessionById(t.assignee)
|
|
1916
|
+
// A card a bot works on opens that bot's conversation, with the card
|
|
1917
|
+
// pinned on top. Only cards with nobody behind them (your questions,
|
|
1918
|
+
// sign-offs, unassigned work) open on their own.
|
|
1919
|
+
if (who && t.kind !== "question") return openSession(who.id, { taskId: t.id })
|
|
1920
|
+
const h = t.handoff
|
|
1921
|
+
const el = openDrawer("task", id, `
|
|
1922
|
+
<div class="dh"><div><div class="s mono">${esc(t.stage ?? t.kind)} · ${esc(t.status)}</div><div style="font-size:16px;font-weight:600">${esc(t.title)}</div></div>
|
|
1923
|
+
<div class="acts"><button class="sm ghost" id="t-close">Close</button></div></div>
|
|
1924
|
+
<div class="kv"><span>owner <b>${who ? "@" + esc(who.handle) : t.owner === "human" ? "you" : esc(t.role ?? "—")}</b></span>
|
|
1925
|
+
<span>created <b>${fmt.ago(t.created)} ago</b></span>${t.startedAt ? `<span>started <b>${fmt.ago(t.startedAt)} ago</b></span>` : ""}</div>
|
|
1926
|
+
<div class="body">
|
|
1927
|
+
${asks(t) ? `<div class="sec">${askBlock(t)}</div>` : ""}
|
|
1928
|
+
${h?.operator_notes ? `<div class="sec"><h3>You said</h3><div class="pre">${esc(h.operator_notes)}</div></div>` : ""}
|
|
1929
|
+
${t.answer ? `<div class="sec"><h3>Your answer</h3><div class="pre">${esc(t.answer)}</div></div>` : ""}
|
|
1930
|
+
${h ? `<div class="sec"><h3>Handoff from @${esc(sessionById(t.assignee)?.handle ?? h.by ?? "")}</h3><div class="pre">${esc(h.summary)}</div></div>
|
|
1931
|
+
${h.acceptance_criteria?.length ? `<div class="sec"><h3>Acceptance criteria</h3><ul>${h.acceptance_criteria.map((a) => `<li>${esc(a)}</li>`).join("")}</ul></div>` : ""}
|
|
1932
|
+
${h.artifacts?.length ? `<div class="sec"><h3>Files</h3><ul class="mono" style="font-size:12px">${h.artifacts.map((a) => `<li>${esc(a)}</li>`).join("")}</ul></div>` : ""}
|
|
1933
|
+
${h.open_questions?.length ? `<div class="sec"><h3>Open questions</h3><ul>${h.open_questions.map((a) => `<li>${esc(a)}</li>`).join("")}</ul></div>` : ""}` : ""}
|
|
1934
|
+
${t.kind !== "question" ? `<div class="sec"><h3>Brief</h3><div class="pre">${esc(t.brief || "—")}</div></div>` : ""}
|
|
1935
|
+
${t.notes?.length ? `<div class="sec"><h3>Notes</h3>${t.notes.map((n) => `<div class="note">${fmt.time(n.at)} ${esc(n.by)}: ${esc(n.text)}</div>`).join("")}</div>` : ""}
|
|
1936
|
+
<div class="sec"><h3>Move</h3><div class="toggles">
|
|
1937
|
+
<button class="sm" data-move="queued">To do</button><button class="sm" data-move="review">Needs you</button>
|
|
1938
|
+
<button class="sm" data-move="done">Done — let the next stage start</button><button class="sm danger" data-move="cancelled">Cancel card</button>
|
|
1939
|
+
${who ? `<button class="sm ghost" id="t-bot">Open @${esc(who.handle)}</button>` : ""}
|
|
1940
|
+
${canSendBack(t) ? `<button class="sm" id="t-sendback">Send back…</button>` : ""}
|
|
1941
|
+
${t.stage && t.runId ? `<button class="sm ghost" id="t-restart">Restart from here…</button>` : ""}</div></div>
|
|
1942
|
+
</div>`)
|
|
1943
|
+
$("#t-close", el).onclick = () => closeDrawer()
|
|
1944
|
+
wireAsk(el, t, () => closeDrawer())
|
|
1945
|
+
for (const b of $$("[data-move]", el)) b.onclick = async () => {
|
|
1946
|
+
await api(`/api/tasks/${encodeURIComponent(t.id)}`, { method: "POST", body: { status: b.dataset.move, queuedAt: Date.now() } }).catch((e) => alert(e.message))
|
|
1947
|
+
closeDrawer()
|
|
1948
|
+
}
|
|
1949
|
+
if ($("#t-bot", el)) $("#t-bot", el).onclick = () => openSession(who.id)
|
|
1950
|
+
if ($("#t-restart", el)) $("#t-restart", el).onclick = () => { closeDrawer(); openRestart(t.projectId, t.stage) }
|
|
1951
|
+
if ($("#t-sendback", el)) $("#t-sendback", el).onclick = async () => { if (await sendBackPrompt(t)) closeDrawer() }
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
// --- what opencode is waiting on for a bot: permissions and its own questions
|
|
1955
|
+
function permissionWhat(p) {
|
|
1956
|
+
const m = p.metadata ?? {}
|
|
1957
|
+
const detail = m.command ?? m.filepath ?? m.filePath ?? m.path ?? m.url ?? (p.patterns ?? []).join(", ")
|
|
1958
|
+
return { action: p.action ?? "permission", detail: detail ? String(detail) : "" }
|
|
1959
|
+
}
|
|
1960
|
+
function botAsksHtml(s) {
|
|
1961
|
+
const perms = (s.permissions ?? []).map((p) => {
|
|
1962
|
+
const w = permissionWhat(p)
|
|
1963
|
+
return `<div class="ask perm-ask" data-perm="${esc(p.id)}" data-s="${esc(s.id)}" style="--fg:${esc(s.color?.fg ?? "")}">
|
|
1964
|
+
<div class="askh"><b>@${esc(s.handle)}</b> wants permission to <b>${esc(w.action)}</b></div>
|
|
1965
|
+
${w.detail ? `<pre class="perm-detail">${esc(w.detail)}</pre>` : ""}
|
|
1966
|
+
${(p.always ?? []).length ? `<div class="note">"Always" allows: <span class="mono">${esc(p.always.join(", "))}</span></div>` : ""}
|
|
1967
|
+
<div class="askf"><span class="note">opencode is paused until you answer.</span>
|
|
1968
|
+
<button class="sm danger" data-reply="reject">Deny</button><button class="sm" data-reply="always">Always allow</button><button class="primary sm" data-reply="once">Allow once</button></div>
|
|
1969
|
+
</div>`
|
|
1970
|
+
})
|
|
1971
|
+
const qs = (s.questions ?? []).map((req) => {
|
|
1972
|
+
const d = drafts.get("oq:" + req.id) ?? []
|
|
1973
|
+
return `<div class="ask" data-oq="${esc(req.id)}" data-s="${esc(s.id)}">
|
|
1974
|
+
<div class="askh"><b>@${esc(s.handle)}</b> asks</div>
|
|
1975
|
+
${req.questions.map((q, i) => {
|
|
1976
|
+
const a = d[i] ?? { picked: [], text: "" }
|
|
1977
|
+
return `<div class="q" data-i="${i}" data-multi="${q.multiple ? 1 : ""}">
|
|
1978
|
+
${q.header ? `<span class="qh">${esc(q.header)}</span>` : ""}
|
|
1979
|
+
<div class="qt">${req.questions.length > 1 ? `<span class="qn">${i + 1}</span>` : ""}${esc(q.question)}</div>
|
|
1980
|
+
${q.options?.length ? `<div class="opts">${q.options.map((o) => `<button type="button" class="opt" role="${q.multiple ? "checkbox" : "radio"}" aria-checked="${a.picked.includes(o.label)}" data-l="${esc(o.label)}">
|
|
1981
|
+
<span class="box"></span><span class="ol"><b>${esc(o.label)}</b>${o.description ? `<i>${esc(o.description)}</i>` : ""}</span></button>`).join("")}</div>` : ""}
|
|
1982
|
+
${q.custom === false ? "" : `<textarea class="qa" rows="1" placeholder="${q.options?.length ? "Or say it in your own words…" : "Your answer…"}">${esc(a.text)}</textarea>`}
|
|
1983
|
+
</div>`
|
|
1984
|
+
}).join("")}
|
|
1985
|
+
<div class="askf"><span class="note">Goes straight back to @${esc(s.handle)}.</span><button class="sm ghost" data-dismiss>Dismiss</button><button class="primary sm" data-send>Send answer</button></div>
|
|
1986
|
+
</div>`
|
|
1987
|
+
})
|
|
1988
|
+
return perms.join("") + qs.join("")
|
|
1989
|
+
}
|
|
1990
|
+
function wireBotAsks(root, s) {
|
|
1991
|
+
for (const box of $$(`.perm-ask[data-s="${CSS.escape(s.id)}"]`, root)) {
|
|
1992
|
+
const p = (s.permissions ?? []).find((x) => x.id === box.dataset.perm)
|
|
1993
|
+
for (const b of $$("[data-reply]", box)) b.onclick = async () => {
|
|
1994
|
+
for (const x of $$("button", box)) x.disabled = true
|
|
1995
|
+
const w = p ? permissionWhat(p) : {}
|
|
1996
|
+
await api(`/api/sessions/${encodeURIComponent(s.id)}/permission`, { method: "POST", body: { requestId: box.dataset.perm, decision: b.dataset.reply, what: `${w.action ?? ""} ${w.detail ?? ""}`.trim().slice(0, 120) } })
|
|
1997
|
+
.catch((e) => { alert(e.message); for (const x of $$("button", box)) x.disabled = false })
|
|
1998
|
+
box.remove()
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
for (const box of $$(`[data-oq][data-s="${CSS.escape(s.id)}"]`, root)) {
|
|
2002
|
+
const id = box.dataset.oq
|
|
2003
|
+
const read = () => $$(".q", box).map((q) => ({ picked: $$(".opt[aria-checked=true]", q).map((o) => o.dataset.l), text: $(".qa", q)?.value.trim() ?? "" }))
|
|
2004
|
+
for (const q of $$(".q", box)) {
|
|
2005
|
+
for (const o of $$(".opt", q)) o.onclick = () => {
|
|
2006
|
+
const on = o.getAttribute("aria-checked") !== "true"
|
|
2007
|
+
if (!q.dataset.multi) for (const x of $$(".opt", q)) x.setAttribute("aria-checked", "false")
|
|
2008
|
+
o.setAttribute("aria-checked", String(on))
|
|
2009
|
+
drafts.set("oq:" + id, read())
|
|
2010
|
+
}
|
|
2011
|
+
if ($(".qa", q)) $(".qa", q).oninput = () => drafts.set("oq:" + id, read())
|
|
2012
|
+
}
|
|
2013
|
+
$("[data-send]", box).onclick = async (e) => {
|
|
2014
|
+
const answers = read().map((a) => [...a.picked, ...(a.text ? [a.text] : [])])
|
|
2015
|
+
if (!answers.some((a) => a.length)) return $(".qa", box)?.focus()
|
|
2016
|
+
e.target.disabled = true
|
|
2017
|
+
await api(`/api/sessions/${encodeURIComponent(s.id)}/question`, { method: "POST", body: { requestId: id, answers } }).catch((err) => alert(err.message))
|
|
2018
|
+
drafts.delete("oq:" + id)
|
|
2019
|
+
box.remove()
|
|
2020
|
+
}
|
|
2021
|
+
$("[data-dismiss]", box).onclick = async () => {
|
|
2022
|
+
await api(`/api/sessions/${encodeURIComponent(s.id)}/question`, { method: "POST", body: { requestId: id, reject: true } }).catch((err) => alert(err.message))
|
|
2023
|
+
box.remove()
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
// --- the card pinned on top of a bot's conversation
|
|
2029
|
+
function cardDetailsHtml(t) {
|
|
2030
|
+
const h = t.handoff
|
|
2031
|
+
const r = t.rework
|
|
2032
|
+
return [
|
|
2033
|
+
r ? `<div class="sec"><h3>${r.early ? "Found while it was being built" : `Sent back by @${esc(r.by)} — round ${r.round}`}</h3><div class="pre">${esc([r.reason, ...(r.failures ?? []).map((f) => "• " + f), r.files?.length ? "files: " + r.files.join(", ") : ""].filter(Boolean).join("\n"))}</div></div>` : "",
|
|
2034
|
+
t.history?.length ? `<div class="sec"><h3>Earlier rounds</h3>${t.history.map((x) => `<details><summary>Round ${x.round}${x.by ? ` · @${esc(x.by)}` : ""}${x.usd != null ? ` · $${Number(x.usd).toFixed(2)}` : ""}</summary><div class="pre">${esc(x.summary || "—")}</div></details>`).join("")}</div>` : "",
|
|
2035
|
+
h?.operator_notes ? `<div class="sec"><h3>You said</h3><div class="pre">${esc(h.operator_notes)}</div></div>` : "",
|
|
2036
|
+
h ? `<div class="sec"><h3>Handoff</h3><div class="pre">${esc(h.summary)}</div></div>` : "",
|
|
2037
|
+
h?.acceptance_criteria?.length ? `<div class="sec"><h3>Acceptance criteria</h3><ul>${h.acceptance_criteria.map((a) => `<li>${esc(a)}</li>`).join("")}</ul></div>` : "",
|
|
2038
|
+
h?.artifacts?.length ? `<div class="sec"><h3>Files</h3><ul class="mono" style="font-size:12px">${h.artifacts.map((a) => `<li>${esc(a)}</li>`).join("")}</ul></div>` : "",
|
|
2039
|
+
h?.open_questions?.length && t.status !== "review" ? `<div class="sec"><h3>Open questions</h3><ul>${h.open_questions.map((a) => `<li>${esc(a)}</li>`).join("")}</ul></div>` : "",
|
|
2040
|
+
t.notes?.length ? `<div class="sec"><h3>Notes</h3>${t.notes.map((n) => `<div class="note">${fmt.time(n.at)} ${esc(n.by)}: ${esc(n.text)}</div>`).join("")}</div>` : "",
|
|
2041
|
+
`<div class="sec"><h3>Brief</h3><div class="pre">${esc(t.brief || "—")}</div></div>`,
|
|
2042
|
+
].join("")
|
|
2043
|
+
}
|
|
2044
|
+
let cardSig = null
|
|
2045
|
+
function renderCardBar() {
|
|
2046
|
+
if (drawer?.kind !== "session") return
|
|
2047
|
+
const bar = $("#x-card")
|
|
2048
|
+
if (!bar) return
|
|
2049
|
+
const mine = (state.tasks ?? []).filter((t) => t.assignee === drawer.id && t.status !== "cancelled").sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
|
2050
|
+
let t = mine.find((x) => x.id === drawer.taskId)
|
|
2051
|
+
?? mine.find((x) => x.status === "active")
|
|
2052
|
+
?? mine.find((x) => !["done", "cancelled"].includes(x.status))
|
|
2053
|
+
?? mine.at(-1)
|
|
2054
|
+
const sig = JSON.stringify([t?.id, t?.status, t?.handoff?.at, t?.notes?.length, mine.map((x) => [x.id, x.status]), drawer.details])
|
|
2055
|
+
if (sig === cardSig) return
|
|
2056
|
+
if (bar.contains(document.activeElement) && document.activeElement.closest(".ask")) return // answering: do not repaint
|
|
2057
|
+
cardSig = sig
|
|
2058
|
+
if (!t) { bar.hidden = true; return }
|
|
2059
|
+
drawer.taskId = t.id
|
|
2060
|
+
bar.hidden = false
|
|
2061
|
+
const col = { done: "done", active: "doing", review: "needs", waiting: "needs" }[t.status] ?? "todo"
|
|
2062
|
+
bar.innerHTML = `
|
|
2063
|
+
<div class="cb1">
|
|
2064
|
+
${mine.length > 1 ? `<select id="cb-pick" title="This bot's cards">${mine.map((x) => `<option value="${esc(x.id)}" ${x.id === t.id ? "selected" : ""}>${esc(x.stage ?? "card")} · ${esc(x.status)}</option>`).join("")}</select>` : t.stage ? `<span class="stage">${esc(t.stage)}</span>` : ""}
|
|
2065
|
+
<b class="ct">${esc(t.title)}</b>
|
|
2066
|
+
<span class="chip st-${col}">${esc(cardStatus(t, state.tasks ?? []))}</span>
|
|
2067
|
+
<span class="acts">
|
|
2068
|
+
<select id="cb-move" title="Move this card">
|
|
2069
|
+
<option value="">Move…</option>
|
|
2070
|
+
<option value="queued">To do</option><option value="review">Needs you</option>
|
|
2071
|
+
<option value="done">Done — next stage starts</option><option value="cancelled">Cancel card</option>
|
|
2072
|
+
${canSendBack(t) ? `<option value="sendback">Send back to its bot…</option>` : ""}
|
|
2073
|
+
</select>
|
|
2074
|
+
${t.stage && t.runId ? `<button class="sm ghost" id="cb-restart">Restart from here…</button>` : ""}
|
|
2075
|
+
<button class="sm ghost" id="cb-more" aria-expanded="${!!drawer.details}">${drawer.details ? "Hide details" : "Details"}</button>
|
|
2076
|
+
</span>
|
|
2077
|
+
</div>
|
|
2078
|
+
${asks(t) ? `<div class="cb-ask">${askBlock(t)}</div>` : ""}
|
|
2079
|
+
${drawer.details ? `<div class="cb-details">${cardDetailsHtml(t)}</div>` : ""}`
|
|
2080
|
+
wireAsk(bar, t, () => { cardSig = null; renderCardBar() })
|
|
2081
|
+
if ($("#cb-pick", bar)) $("#cb-pick", bar).onchange = (e) => { drawer.taskId = e.target.value; cardSig = null; renderCardBar() }
|
|
2082
|
+
$("#cb-more", bar).onclick = () => { drawer.details = !drawer.details; cardSig = null; renderCardBar() }
|
|
2083
|
+
$("#cb-move", bar).onchange = async (e) => {
|
|
2084
|
+
const status = e.target.value
|
|
2085
|
+
if (!status) return
|
|
2086
|
+
if (status === "sendback") { await sendBackPrompt(t); e.target.value = ""; return }
|
|
2087
|
+
if (status === "cancelled" && !confirm("Cancel this card?")) { e.target.value = ""; return }
|
|
2088
|
+
await api(`/api/tasks/${encodeURIComponent(t.id)}`, { method: "POST", body: { status, queuedAt: Date.now() } }).catch((err) => alert(err.message))
|
|
2089
|
+
}
|
|
2090
|
+
if ($("#cb-restart", bar)) $("#cb-restart", bar).onclick = () => openRestart(t.projectId, t.stage)
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
// --- questions for you: choices where the bot offered them, your own words always
|
|
2094
|
+
const drafts = new Map() // task id -> [{ picked: [labels], text }], shared by the card and the chat
|
|
2095
|
+
const asks = (t) => (t.kind === "question" && !["done", "cancelled", "blocked"].includes(t.status)) || t.status === "review"
|
|
2096
|
+
function questionsOf(t) {
|
|
2097
|
+
// A sign-off stage in a pipeline: the question is the stage itself.
|
|
2098
|
+
if (t.kind === "question" && t.stage && !t.questions?.length) {
|
|
2099
|
+
return [{ question: t.title, options: [{ label: "Looks good — ship it" }, { label: "Needs changes", description: "Say what in your own words" }], multiple: false }]
|
|
2100
|
+
}
|
|
2101
|
+
if (t.kind === "question") return t.questions?.length ? t.questions : [{ question: t.brief, options: (t.options ?? []).map((l) => ({ label: l })), multiple: false }]
|
|
2102
|
+
const h = t.handoff
|
|
2103
|
+
return h?.questions?.length ? h.questions : (h?.open_questions ?? []).map((q) => ({ question: q, options: [], multiple: false }))
|
|
2104
|
+
}
|
|
2105
|
+
function askBlock(t) {
|
|
2106
|
+
if (t.budget) return budgetAskBlock(t)
|
|
2107
|
+
const qs = questionsOf(t)
|
|
2108
|
+
const d = drafts.get(t.id) ?? []
|
|
2109
|
+
const review = t.kind !== "question"
|
|
2110
|
+
const who = sessionById(t.askedBy ?? t.assignee)
|
|
2111
|
+
return `<div class="ask" data-ask="${esc(t.id)}">
|
|
2112
|
+
<div class="askh">${review ? `<b>${esc(t.stage ?? t.title)}</b> is waiting for you before the next stage` : t.stage ? `<b>${esc(t.stage)}</b> is yours` : `<b>@${esc(who?.handle ?? "a bot")}</b> asks`}</div>
|
|
2113
|
+
${t.stage && t.kind === "question" ? (() => {
|
|
2114
|
+
const prev = (state.tasks ?? []).filter((x) => t.dependsOn?.includes(x.id) && x.handoff?.summary)
|
|
2115
|
+
return prev.map((x) => `<div class="note" style="white-space:pre-wrap">${esc(x.stage)}: ${esc(x.handoff.summary)}</div>`).join("")
|
|
2116
|
+
})() : ""}
|
|
2117
|
+
${qs.map((q, i) => {
|
|
2118
|
+
const a = d[i] ?? { picked: [], text: "" }
|
|
2119
|
+
return `<div class="q" data-i="${i}" data-multi="${q.multiple ? 1 : ""}">
|
|
2120
|
+
${q.header ? `<span class="qh">${esc(q.header)}</span>` : ""}
|
|
2121
|
+
<div class="qt">${qs.length > 1 ? `<span class="qn">${i + 1}</span>` : ""}${esc(q.question)}</div>
|
|
2122
|
+
${q.options?.length ? `<div class="opts">${q.options.map((o) => `<button type="button" class="opt" role="${q.multiple ? "checkbox" : "radio"}" aria-checked="${a.picked.includes(o.label)}" data-l="${esc(o.label)}">
|
|
2123
|
+
<span class="box"></span><span class="ol"><b>${esc(o.label)}</b>${o.description ? `<i>${esc(o.description)}</i>` : ""}</span></button>`).join("")}</div>` : ""}
|
|
2124
|
+
<textarea class="qa" rows="${q.options?.length ? 1 : 2}" placeholder="${q.options?.length ? "Or say it in your own words…" : "Your answer…"}">${esc(a.text)}</textarea>
|
|
2125
|
+
</div>`
|
|
2126
|
+
}).join("")}
|
|
2127
|
+
${review && !qs.length ? `<textarea class="qnote" rows="2" placeholder="Anything the next bots should know (optional)"></textarea>` : ""}
|
|
2128
|
+
<div class="askf"><span class="note">${review ? "Your answers go to every later stage and the chat." : "Goes straight back to the bot that asked."}</span>
|
|
2129
|
+
<button class="primary sm" data-send>${review ? "Continue to the next stage" : "Send answer"}</button></div>
|
|
2130
|
+
</div>`
|
|
2131
|
+
}
|
|
2132
|
+
function wireAsk(root, t, done = () => {}) {
|
|
2133
|
+
if (t.budget) return wireBudgetAsk(root, t, done)
|
|
2134
|
+
const box = $(`[data-ask="${CSS.escape(t.id)}"]`, root)
|
|
2135
|
+
if (!box) return
|
|
2136
|
+
const qs = questionsOf(t)
|
|
2137
|
+
const read = () => $$(".q", box).map((q) => ({
|
|
2138
|
+
picked: $$(".opt[aria-checked=true]", q).map((o) => o.dataset.l),
|
|
2139
|
+
text: $(".qa", q).value.trim(),
|
|
2140
|
+
}))
|
|
2141
|
+
const save = () => drafts.set(t.id, read())
|
|
2142
|
+
for (const q of $$(".q", box)) {
|
|
2143
|
+
for (const o of $$(".opt", q)) o.onclick = () => {
|
|
2144
|
+
const on = o.getAttribute("aria-checked") !== "true"
|
|
2145
|
+
if (!q.dataset.multi) for (const x of $$(".opt", q)) x.setAttribute("aria-checked", "false")
|
|
2146
|
+
o.setAttribute("aria-checked", String(on))
|
|
2147
|
+
save()
|
|
2148
|
+
}
|
|
2149
|
+
$(".qa", q).oninput = (e) => { save(); e.target.style.height = "auto"; e.target.style.height = Math.min(200, e.target.scrollHeight) + "px" }
|
|
2150
|
+
}
|
|
2151
|
+
$("[data-send]", box).onclick = async (e) => {
|
|
2152
|
+
const answers = read()
|
|
2153
|
+
const note = $(".qnote", box)?.value.trim() ?? ""
|
|
2154
|
+
const review = t.kind !== "question"
|
|
2155
|
+
if (!review && !answers.some((a) => a.picked.length || a.text)) return $(".qa", box).focus()
|
|
2156
|
+
e.target.disabled = true
|
|
2157
|
+
try {
|
|
2158
|
+
if (review) await api(`/api/tasks/${encodeURIComponent(t.id)}/continue`, { method: "POST", body: { answers: qs.length ? answers : null, note } })
|
|
2159
|
+
else await api(`/api/tasks/${encodeURIComponent(t.id)}/answer`, { method: "POST", body: { answers } })
|
|
2160
|
+
drafts.delete(t.id)
|
|
2161
|
+
done()
|
|
2162
|
+
} catch (err) { alert(err.message); e.target.disabled = false }
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
function openStory(pid) {
|
|
2167
|
+
const p = (state.projects ?? []).find((x) => x.id === pid)
|
|
2168
|
+
const el = openDrawer("story", pid, `
|
|
2169
|
+
<div class="dh"><div><div class="s">Story</div><div style="font-size:16px;font-weight:600">${esc(p.name)}</div></div>
|
|
2170
|
+
<div class="acts"><button class="sm ghost" id="s-close">Close</button></div></div>
|
|
2171
|
+
<div class="kv"><span>pipeline <b>${esc(p.pipelineTitle ?? "—")}</b></span><span>branch <b>${esc(p.branch ?? "—")}</b></span><span>worktree <b>${esc(fmt.path(p.worktree ?? p.repo))}</b></span>
|
|
2172
|
+
<span>bots listed in <b>.botfarm/workstreams/${esc(p.id)}.yml</b></span></div>
|
|
2173
|
+
<div class="body"><div class="pre" style="white-space:pre-wrap">${esc(p.story ?? "No story recorded for this workstream.")}</div></div>`)
|
|
2174
|
+
$("#s-close", el).onclick = () => closeDrawer()
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
// --- a bot's live transcript
|
|
2178
|
+
let live = { id: null, msgs: null, status: null, currentTool: null, at: 0 }
|
|
2179
|
+
const openThoughts = new Set()
|
|
2180
|
+
let liveFrame = 0
|
|
2181
|
+
async function openSession(id, { taskId = null } = {}) {
|
|
2182
|
+
const s = sessionById(id)
|
|
2183
|
+
if (!s) return
|
|
2184
|
+
const el = openDrawer("session", id, `
|
|
2185
|
+
<div class="dh"><img src="${avatarUrl(s)}" alt="" /><div><div class="h">@${esc(s.handle)}</div><div class="s">${esc(s.personaTitle ?? s.role ?? s.persona ?? "")}${s.project ? ` · ${esc((state.projects ?? []).find((p) => p.id === s.project)?.name ?? "")}` : ""}</div></div>
|
|
2186
|
+
<div class="acts"><button class="sm" id="x-tune" title="Switch this bot to another model or reasoning level">Model…</button><button class="sm danger" id="x-abort">Stop</button><button class="sm ghost" id="x-close">Close</button></div></div>
|
|
2187
|
+
<div class="kv" id="x-kv"></div>
|
|
2188
|
+
<div class="tune" id="x-tunebox" hidden></div>
|
|
2189
|
+
<div class="cardbar" id="x-card" hidden></div>
|
|
2190
|
+
<div class="transcript" id="x-tr"><div class="empty">Loading…</div></div>
|
|
2191
|
+
<div class="x-asks" id="x-asks"></div>
|
|
2192
|
+
<div class="composer"><textarea id="x-input" rows="1" placeholder="Talk to @${esc(s.handle)} directly…"></textarea><button class="primary" id="x-send">Send</button></div>`)
|
|
2193
|
+
live = { id, msgs: null, status: s.status, currentTool: s.currentTool, at: 0, handle: s.handle }
|
|
2194
|
+
drawer.taskId = taskId
|
|
2195
|
+
cardSig = null
|
|
2196
|
+
xAsksSig = null
|
|
2197
|
+
renderCardBar()
|
|
2198
|
+
wsSend(`watch:${id}`)
|
|
2199
|
+
$("#x-close", el).onclick = () => closeDrawer()
|
|
2200
|
+
$("#x-tune", el).onclick = () => openTune(id)
|
|
2201
|
+
$("#x-abort", el).onclick = () => api(`/api/sessions/${encodeURIComponent(id)}/abort`, { method: "POST" })
|
|
2202
|
+
const input = $("#x-input", el)
|
|
2203
|
+
const send = async () => {
|
|
2204
|
+
const text = input.value.trim()
|
|
2205
|
+
if (!text) return
|
|
2206
|
+
input.value = ""
|
|
2207
|
+
await api(`/api/sessions/${encodeURIComponent(id)}/send`, { method: "POST", body: { text } }).catch((e) => alert(e.message))
|
|
2208
|
+
}
|
|
2209
|
+
$("#x-send", el).onclick = send
|
|
2210
|
+
input.onkeydown = (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send() } }
|
|
2211
|
+
$("#x-tr", el).addEventListener("toggle", (e) => {
|
|
2212
|
+
const pid = e.target.dataset?.p
|
|
2213
|
+
if (pid) e.target.open ? openThoughts.add(pid) : openThoughts.delete(pid)
|
|
2214
|
+
}, true)
|
|
2215
|
+
renderKv()
|
|
2216
|
+
// The socket sends the transcript as soon as we start watching; this is
|
|
2217
|
+
// only for when the socket is not there.
|
|
2218
|
+
const d = await api(`/api/sessions/${encodeURIComponent(id)}`).catch(() => null)
|
|
2219
|
+
if (d && live.id === id && !live.msgs) { live.msgs = (d.transcript ?? []).slice().reverse(); paintTranscript() }
|
|
2220
|
+
}
|
|
2221
|
+
// --- upgrade / downgrade a running bot: model and reasoning level, from its next turn
|
|
2222
|
+
async function openTune(id) {
|
|
2223
|
+
const box = $("#x-tunebox")
|
|
2224
|
+
if (!box) return
|
|
2225
|
+
if (!box.hidden) { box.hidden = true; return }
|
|
2226
|
+
box.hidden = false
|
|
2227
|
+
box.innerHTML = `<div class="note">Loading models…</div>`
|
|
2228
|
+
let info
|
|
2229
|
+
try { info = await api(`/api/sessions/${encodeURIComponent(id)}/models`) } catch (e) { box.innerHTML = `<div class="err">${esc(e.message)}</div>`; return }
|
|
2230
|
+
const cur = info.current ?? {}
|
|
2231
|
+
const variantsFor = (mid) => { const m = info.models.find((x) => x.id === mid); return m?.variants?.length ? m.variants : ["low", "medium", "high"] }
|
|
2232
|
+
const quick = ["opus", "sonnet", "haiku"].map((k) => info.models.find((m) => new RegExp(k, "i").test(m.id) && m.provider === (cur.model ?? "").split("/")[0]) ?? info.models.find((m) => new RegExp(k, "i").test(m.id))).filter(Boolean)
|
|
2233
|
+
const paint = () => {
|
|
2234
|
+
const mid = $("#tu-model")?.value ?? cur.model ?? ""
|
|
2235
|
+
$("#tu-variant").innerHTML = [`<option value="default">default${info.fromAgent.variant ? ` (${esc(info.fromAgent.variant)})` : ""}</option>`, ...variantsFor(mid).map((v) => `<option value="${esc(v)}" ${v === cur.variant ? "selected" : ""}>${esc(v)}</option>`)].join("")
|
|
2236
|
+
}
|
|
2237
|
+
box.innerHTML = `<div class="tuneh"><b>Model and reasoning for this bot</b><span class="note">Applies from its next turn. The agent file is not changed.</span></div>
|
|
2238
|
+
<div class="row">
|
|
2239
|
+
<div class="field"><label for="tu-model">Model</label><input id="tu-model" class="mono" list="tu-models" value="${esc(cur.model ?? "")}" placeholder="opencode default" />
|
|
2240
|
+
<datalist id="tu-models">${info.models.map((m) => `<option value="${esc(m.id)}">${esc(m.provider)} · ${esc(m.name)}${m.variants?.length ? " · " + esc(m.variants.join("/")) : ""}</option>`).join("")}</datalist></div>
|
|
2241
|
+
<div class="field" style="max-width:170px"><label for="tu-variant">Reasoning</label><select id="tu-variant"></select></div>
|
|
2242
|
+
</div>
|
|
2243
|
+
${quick.length ? `<div class="toggles" style="margin:-4px 0 10px">${quick.map((m) => `<button class="toggle" data-q="${esc(m.id)}" aria-pressed="${m.id === cur.model}">${esc(m.name)}</button>`).join("")}</div>` : ""}
|
|
2244
|
+
<div class="askf"><span class="note">${info.override ? `Overridden here — the agent says ${esc(info.fromAgent.model ?? "default")}${info.fromAgent.variant ? " · " + esc(info.fromAgent.variant) : ""}.` : `From the agent definition.`}</span>
|
|
2245
|
+
${info.override ? `<button class="sm ghost" id="tu-reset">Back to the agent's</button>` : ""}
|
|
2246
|
+
<button class="sm ghost" id="tu-cancel">Cancel</button><button class="primary sm" id="tu-apply">Apply</button></div>`
|
|
2247
|
+
paint()
|
|
2248
|
+
$("#tu-model").oninput = paint
|
|
2249
|
+
for (const b of $$("[data-q]", box)) b.onclick = () => { $("#tu-model").value = b.dataset.q; for (const x of $$("[data-q]", box)) x.setAttribute("aria-pressed", String(x === b)); paint() }
|
|
2250
|
+
const send = async (body) => {
|
|
2251
|
+
try { await api(`/api/sessions/${encodeURIComponent(id)}/settings`, { method: "POST", body }); box.hidden = true } catch (e) { alert(e.message) }
|
|
2252
|
+
}
|
|
2253
|
+
$("#tu-apply").onclick = () => send({ model: $("#tu-model").value.trim() || null, variant: $("#tu-variant").value })
|
|
2254
|
+
$("#tu-cancel").onclick = () => { box.hidden = true }
|
|
2255
|
+
if ($("#tu-reset")) $("#tu-reset").onclick = () => send({ model: null, variant: null })
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
let xAsksSig = null
|
|
2259
|
+
function renderDrawerAsks() {
|
|
2260
|
+
const box = $("#x-asks")
|
|
2261
|
+
const s = sessionById(drawer?.id)
|
|
2262
|
+
if (!box || !s) return
|
|
2263
|
+
const sig = JSON.stringify([s.permissions?.map((x) => x.id), s.questions?.map((x) => x.id)])
|
|
2264
|
+
if (sig === xAsksSig || box.contains(document.activeElement)) return
|
|
2265
|
+
xAsksSig = sig
|
|
2266
|
+
box.innerHTML = botAsksHtml(s)
|
|
2267
|
+
wireBotAsks(box, s)
|
|
2268
|
+
}
|
|
2269
|
+
function renderKv() {
|
|
2270
|
+
if (drawer?.kind !== "session") return
|
|
2271
|
+
renderCardBar()
|
|
2272
|
+
renderDrawerAsks()
|
|
2273
|
+
const s = sessionById(drawer.id)
|
|
2274
|
+
const kv = $("#x-kv")
|
|
2275
|
+
if (!s || !kv) return
|
|
2276
|
+
kv.innerHTML = `<span>status <b>${esc(s.status)}</b></span><span>model <b>${esc(s.model ?? s.configuredModel ?? "opencode default")}</b>${s.variant ? ` · <b>${esc(s.variant)}</b>` : ""}</span>
|
|
2277
|
+
${s.mode ? `<span>agent <b>${esc(s.mode)}</b></span>` : ""}
|
|
2278
|
+
<span>tokens <b>${fmt.n(s.tokens)}</b> (in ${fmt.n(s.totals?.input)} · out ${fmt.n(s.totals?.output)} · cache ${fmt.n(s.totals?.cacheRead)})</span>
|
|
2279
|
+
<span>${s.cost?.estimated ? "est." : ""} <b>${s.cost?.unknownModel ? "$? — no price set" : fmt.money(s.cost?.usd)}</b></span>
|
|
2280
|
+
<span>runs <b>${s.invocations ?? 0}</b></span><span>tools <b>${s.totals?.tools ?? 0}</b>${s.totals?.toolErrors ? ` (${s.totals.toolErrors} failed)` : ""}</span>
|
|
2281
|
+
<span>MCP <b>${s.mcp ? s.mcp.servers.map((m) => `<span title="${esc(m.error ?? m.status)}">${esc(m.name)}${m.connected ? "" : " (" + esc(m.status) + ")"}</span>`).join(", ") || "none" : "—"}</b>
|
|
2282
|
+
${s.mcp?.servers?.some((m) => ["failed", "error", "disconnected"].includes(m.status)) ? `<button class="sm ghost" data-mcp-reconnect="${esc(s.id)}">Reconnect</button>` : ""}</span>
|
|
2283
|
+
<span>worktree <b>${esc(fmt.path(s.directory))}</b></span>`
|
|
2284
|
+
const rb = kv.querySelector("[data-mcp-reconnect]")
|
|
2285
|
+
if (rb) rb.onclick = async () => {
|
|
2286
|
+
rb.disabled = true
|
|
2287
|
+
rb.textContent = "Reconnecting…"
|
|
2288
|
+
await api(`/api/sessions/${encodeURIComponent(s.id)}/mcp/reconnect`, { method: "POST", body: {} }).catch((e) => alert(e.message))
|
|
2289
|
+
}
|
|
2290
|
+
if (live.id === s.id && live.status !== s.status) { live.status = s.status; paintTranscript() }
|
|
2291
|
+
}
|
|
2292
|
+
function transcriptBody() {
|
|
2293
|
+
const msgs = live.msgs ?? []
|
|
2294
|
+
if (!msgs.length) return `<div class="empty">Nothing in this session yet.</div>`
|
|
2295
|
+
const who = { id: live.id, handle: live.handle }
|
|
2296
|
+
const body = msgs.map((m) => {
|
|
2297
|
+
const bot = m.role === "assistant"
|
|
2298
|
+
const parts = m.parts?.length ? m.parts : [
|
|
2299
|
+
...(m.text ? [{ id: m.id + ":t", kind: "text", text: m.text }] : []),
|
|
2300
|
+
...(m.tools ?? []).map((t, i) => ({ id: m.id + ":" + i, kind: "tool", name: t.name, status: t.status })),
|
|
2301
|
+
]
|
|
2302
|
+
return `<div class="turn ${esc(m.role)}">
|
|
2303
|
+
${bot ? `<img src="${avatarUrl(who)}" alt="" />` : `<span class="avdot"></span>`}
|
|
2304
|
+
<div class="c"><div class="m">${bot ? "@" + esc(live.handle) : m.role === "user" ? "you / BotFarm" : esc(m.role)}${m.at ? " · " + fmt.time(m.at) : ""}</div>
|
|
2305
|
+
${parts.map((p) => partHtml(p, bot)).join("") || `<span class="note">…</span>`}</div></div>`
|
|
2306
|
+
}).join("")
|
|
2307
|
+
const working = live.status === "busy" ? `<div class="working"><i></i>@${esc(live.handle)} is ${live.currentTool ? `running ${esc(live.currentTool.name)}` : "working"}…</div>` : ""
|
|
2308
|
+
return body + working
|
|
2309
|
+
}
|
|
2310
|
+
function partHtml(p, bot) {
|
|
2311
|
+
const open = openThoughts.has(p.id) ? "open" : ""
|
|
2312
|
+
if (p.kind === "text") {
|
|
2313
|
+
const text = p.text ?? ""
|
|
2314
|
+
if (!bot && text.length > 600) {
|
|
2315
|
+
return `<details class="more" data-p="${esc(p.id)}" ${open}><summary><div class="b long">${esc(text.slice(0, 600))}</div>
|
|
2316
|
+
<span class="lbl">show all ${fmt.n(text.length)} characters</span><span class="lbl less">show less</span></summary><div class="b">${esc(text)}</div></details>`
|
|
2317
|
+
}
|
|
2318
|
+
return `<div class="b">${esc(text)}</div>`
|
|
2319
|
+
}
|
|
2320
|
+
if (p.kind === "reasoning") {
|
|
2321
|
+
const words = (p.text ?? "").split(/\s+/).filter(Boolean).length
|
|
2322
|
+
return `<details class="think" data-p="${esc(p.id)}" ${open}><summary class="lbl">thinking · ${words} words</summary><div>${esc(p.text ?? "")}</div></details>`
|
|
2323
|
+
}
|
|
2324
|
+
if (p.kind === "tool") {
|
|
2325
|
+
const st = p.status === "completed" ? (p.ms != null ? fmt.ms(p.ms) : "done") : p.status ?? ""
|
|
2326
|
+
const row = `<div class="call s-${esc(p.status ?? "")}"><span class="nm">${esc(p.name ?? "tool")}</span><span class="sm">${esc(p.summary ?? "")}</span><span class="st">${esc(st)}</span></div>`
|
|
2327
|
+
return p.output ? `<details data-p="${esc(p.id)}" ${open}><summary>${row}</summary><div class="out">${esc(p.output)}</div></details>` : row
|
|
2328
|
+
}
|
|
2329
|
+
return ""
|
|
2330
|
+
}
|
|
2331
|
+
function paintTranscript() {
|
|
2332
|
+
if (liveFrame) return
|
|
2333
|
+
liveFrame = requestAnimationFrame(() => {
|
|
2334
|
+
liveFrame = 0
|
|
2335
|
+
const tr = $("#x-tr")
|
|
2336
|
+
if (!tr || !live.msgs) return
|
|
2337
|
+
const atBottom = tr.scrollHeight - tr.scrollTop - tr.clientHeight < 80 || !tr.dataset.painted
|
|
2338
|
+
tr.innerHTML = transcriptBody()
|
|
2339
|
+
tr.dataset.painted = "1"
|
|
2340
|
+
if (atBottom) tr.scrollTop = tr.scrollHeight
|
|
2341
|
+
})
|
|
2342
|
+
}
|
|
2343
|
+
function onLive(f) {
|
|
2344
|
+
if (drawer?.kind !== "session" || f.sessionId !== drawer.id || live.id !== drawer.id) return
|
|
2345
|
+
live.at = Date.now()
|
|
2346
|
+
if (f.t === "transcript") {
|
|
2347
|
+
// The REST snapshot behind this frame can lag the streaming deltas we already
|
|
2348
|
+
// painted (a still-in-flight assistant turn's text/reasoning parts are not
|
|
2349
|
+
// always fully flushed server-side yet), so a blind overwrite here made text
|
|
2350
|
+
// visibly vanish and reappear on every poll while tool parts (whose status the
|
|
2351
|
+
// server does track live) stayed put. Keep whichever text is longer per part.
|
|
2352
|
+
const prevById = new Map((live.msgs ?? []).map((m) => [m.id, m]))
|
|
2353
|
+
live.msgs = (f.transcript ?? []).slice().reverse().map((m) => {
|
|
2354
|
+
const prev = prevById.get(m.id)
|
|
2355
|
+
if (!prev?.parts?.length || !m.parts?.length) return m
|
|
2356
|
+
const prevParts = new Map(prev.parts.map((p) => [p.id, p]))
|
|
2357
|
+
return { ...m, parts: m.parts.map((p) => {
|
|
2358
|
+
const old = prevParts.get(p.id)
|
|
2359
|
+
if (old && typeof old.text === "string" && typeof p.text === "string" && old.text.length > p.text.length) {
|
|
2360
|
+
return { ...p, text: old.text }
|
|
2361
|
+
}
|
|
2362
|
+
return p
|
|
2363
|
+
}) }
|
|
2364
|
+
})
|
|
2365
|
+
live.status = f.status
|
|
2366
|
+
live.currentTool = f.currentTool
|
|
2367
|
+
} else if (f.t === "part") {
|
|
2368
|
+
if (!live.msgs) return
|
|
2369
|
+
let m = live.msgs.find((x) => x.id === f.messageId)
|
|
2370
|
+
if (!m) {
|
|
2371
|
+
if (!f.messageId) return
|
|
2372
|
+
m = { id: f.messageId, role: "assistant", at: Date.now(), parts: [] }
|
|
2373
|
+
live.msgs.push(m)
|
|
2374
|
+
}
|
|
2375
|
+
m.parts ??= []
|
|
2376
|
+
let part = m.parts.find((x) => x.id === f.part.id)
|
|
2377
|
+
if (!part) { part = { ...f.part }; m.parts.push(part); if (f.delta && !f.part.text) part.text = f.delta }
|
|
2378
|
+
else if (f.delta && f.part.text == null) part.text = (part.text ?? "") + f.delta
|
|
2379
|
+
else Object.assign(part, Object.fromEntries(Object.entries(f.part).filter(([, v]) => v != null)))
|
|
2380
|
+
if (f.part.kind === "tool") live.currentTool = /running|pending/.test(f.part.status) ? { name: f.part.name } : null
|
|
2381
|
+
live.status = "busy"
|
|
2382
|
+
}
|
|
2383
|
+
paintTranscript()
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
// ------------------------------------------------------------------ dialogs
|
|
2387
|
+
function wireDialog(id, cancelId) {
|
|
2388
|
+
const d = $(id)
|
|
2389
|
+
$(cancelId).onclick = () => d.close()
|
|
2390
|
+
return d
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
// --- folder browser: a page cannot ask the OS for a path, so the server lists folders
|
|
2394
|
+
const dFolder = wireDialog("#d-folder", "#fs-cancel")
|
|
2395
|
+
let fsHere = null
|
|
2396
|
+
async function browse(path) {
|
|
2397
|
+
$("#fs-err").textContent = ""
|
|
2398
|
+
const r = await api("/api/fs?path=" + encodeURIComponent(path)).catch((e) => ({ error: e.message, dirs: [] }))
|
|
2399
|
+
fsHere = r
|
|
2400
|
+
$("#fs-path").value = fmt.path(r.path ?? path)
|
|
2401
|
+
$("#fs-up").disabled = !r.parent
|
|
2402
|
+
$("#fs-err").textContent = r.error ?? ""
|
|
2403
|
+
$("#fs-list").innerHTML = r.dirs.map((d) => `<button data-p="${esc(d.path)}">📁 ${esc(d.name)}${d.isRepo ? `<span class="git">git</span>` : ""}</button>`).join("")
|
|
2404
|
+
|| `<div class="empty" style="padding:10px">No folders in here.</div>`
|
|
2405
|
+
$("#fs-use").textContent = r.isRepo ? "Use this repository" : "Use this folder"
|
|
2406
|
+
for (const b of $$("#fs-list button")) b.onclick = () => browse(b.dataset.p)
|
|
2407
|
+
}
|
|
2408
|
+
function openFolder() {
|
|
2409
|
+
dFolder.showModal()
|
|
2410
|
+
browse(currentWs() ? currentWs().path.replace(/\/[^/]+$/, "") : "~/workspace")
|
|
2411
|
+
}
|
|
2412
|
+
$("#fs-up").onclick = () => fsHere?.parent && browse(fsHere.parent)
|
|
2413
|
+
$("#fs-go").onclick = () => browse($("#fs-path").value)
|
|
2414
|
+
$("#fs-path").onkeydown = (e) => { if (e.key === "Enter") { e.preventDefault(); browse($("#fs-path").value) } }
|
|
2415
|
+
$("#fs-use").onclick = async () => {
|
|
2416
|
+
try {
|
|
2417
|
+
const ws = await api("/api/workspaces", { method: "POST", body: { path: fsHere?.path ?? $("#fs-path").value } })
|
|
2418
|
+
dFolder.close()
|
|
2419
|
+
libs.delete(ws.id)
|
|
2420
|
+
go("home", null, ws.id)
|
|
2421
|
+
} catch (e) { $("#fs-err").textContent = e.message }
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
// --- the work as patches, applied to the real repos
|
|
2425
|
+
const dApply = wireDialog("#d-apply", "#ap-cancel")
|
|
2426
|
+
let apProject = null
|
|
2427
|
+
const AP_HOW = {
|
|
2428
|
+
clean: ["✓ applies cleanly", "ok"], "3way": ["↔ needs a 3-way merge (no conflicts)", "busy"], conflict: ["⚠ will conflict — markers to resolve", "err"],
|
|
2429
|
+
nothing: ["no changes", "dim"], present: ["✓ already in your repo", "ok"], conflicts: ["⚠ applied with conflicts", "err"], partial: ["⚠ partly applied — see .rej files", "err"], failed: ["✗ not applied", "err"],
|
|
2430
|
+
}
|
|
2431
|
+
const apBadge = (how, done = false) => { const [t, c] = done && how === "clean" ? ["✓ applied", "ok"] : AP_HOW[how] ?? [how, "dim"]; return `<span class="apb ${c}">${esc(t)}</span>` }
|
|
2432
|
+
async function openApply(projectId) {
|
|
2433
|
+
apProject = projectId
|
|
2434
|
+
$("#ap-body").innerHTML = `<div class="note">Working out the patches…</div>`
|
|
2435
|
+
$("#ap-err").textContent = ""
|
|
2436
|
+
$("#ap-go").disabled = true
|
|
2437
|
+
$("#ap-undo").hidden = true
|
|
2438
|
+
dApply.showModal()
|
|
2439
|
+
let v
|
|
2440
|
+
try { v = await api(`/api/projects/${encodeURIComponent(projectId)}/patches`) } catch (e) { $("#ap-body").innerHTML = `<div class="err">${esc(e.message)}</div>`; return }
|
|
2441
|
+
const withWork = v.repos.filter((r) => r.files?.length)
|
|
2442
|
+
const lastClean = (v.applied?.results ?? []).some((r) => r.how === "clean") && !v.applied?.undone
|
|
2443
|
+
$("#ap-undo").hidden = !lastClean
|
|
2444
|
+
$("#ap-body").innerHTML = `
|
|
2445
|
+
${v.busy.length ? `<div class="warnbox">@${v.busy.map(esc).join(", @")} ${v.busy.length > 1 ? "are" : "is"} still working — you get what is in the worktree right now.</div>` : ""}
|
|
2446
|
+
${v.applied ? `<div class="note">Last applied ${fmt.ago(v.applied.at)} ago${v.applied.undone ? " (undone)" : ""}: ${v.applied.results.filter((r) => r.how !== "nothing").map((r) => `${esc(r.rel || "parent")} ${apBadge(r.how, true)}`).join(" · ") || "nothing"}</div>` : ""}
|
|
2447
|
+
<table class="aptab"><thead><tr><th></th><th>Repo</th><th>Goes into</th><th>Changes</th><th></th></tr></thead><tbody>
|
|
2448
|
+
${v.repos.map((r) => `<tr>
|
|
2449
|
+
<td><input type="checkbox" data-aprel="${esc(r.rel)}" ${r.files?.length && r.check?.how !== "present" ? "checked" : r.files?.length ? "" : "disabled"} /></td>
|
|
2450
|
+
<td class="mono">${esc(r.rel ? r.rel + "/" : r.name + " (parent)")}</td>
|
|
2451
|
+
<td><div class="mono" style="font-size:12px">${esc(r.real)}</div><div class="note">${r.realBranch ? `on <b>${esc(r.realBranch)}</b>` : ""}${r.realDirty ? ` · ${r.realDirty} uncommitted file${r.realDirty > 1 ? "s" : ""} already there` : ""}</div>
|
|
2452
|
+
${r.overlap?.length ? `<div class="note" style="color:var(--busy)">you have uncommitted edits to ${r.overlap.map(esc).join(", ")}</div>` : ""}</td>
|
|
2453
|
+
<td>${r.error ? `<span class="err">${esc(r.error)}</span>` : r.files?.length ? `<details><summary>${r.files.length} file${r.files.length > 1 ? "s" : ""} <span class="mono" style="color:var(--ok)">+${r.files.reduce((a, f) => a + (f.added ?? 0), 0)}</span> <span class="mono" style="color:var(--error)">−${r.files.reduce((a, f) => a + (f.removed ?? 0), 0)}</span>${r.commits ? ` · ${r.commits} commit${r.commits > 1 ? "s" : ""}` : ""}</summary><div class="mono" style="font-size:12px">${r.files.map((f) => esc(f.path)).join("<br>")}</div></details>` : `<span class="note">no changes</span>`}</td>
|
|
2454
|
+
<td>${r.check ? apBadge(r.check.how) : ""}${r.file ? `<br><a class="note" href="/api/projects/${encodeURIComponent(projectId)}/patches/file?rel=${encodeURIComponent(r.rel)}" download>download .patch</a>` : ""}</td></tr>`).join("")}
|
|
2455
|
+
</tbody></table>
|
|
2456
|
+
<div class="note mono" style="margin-top:6px">Patches: ${esc(v.dir)}</div>`
|
|
2457
|
+
const count = () => $$("[data-aprel]:checked", $("#ap-body")).length
|
|
2458
|
+
const label = () => { const n = count(); $("#ap-go").disabled = !n; $("#ap-go").textContent = n ? `Apply to ${n} repo${n > 1 ? "s" : ""}` : "Apply" }
|
|
2459
|
+
for (const c of $$("[data-aprel]", $("#ap-body"))) c.onchange = label
|
|
2460
|
+
label()
|
|
2461
|
+
if (!withWork.length) $("#ap-err").textContent = "Nothing to apply — the worktrees match your repos."
|
|
2462
|
+
}
|
|
2463
|
+
$("#ap-go").onclick = async () => {
|
|
2464
|
+
const repos = $$("[data-aprel]:checked", $("#ap-body")).map((c) => c.dataset.aprel)
|
|
2465
|
+
const risky = repos.length && $$("[data-aprel]:checked", $("#ap-body")).some((c) => c.closest("tr").querySelector(".apb.err"))
|
|
2466
|
+
if (risky && !confirm("Some of these will conflict. They are applied as far as they go, with conflict markers (or .rej files) to sort out. Go ahead?")) return
|
|
2467
|
+
$("#ap-go").disabled = true
|
|
2468
|
+
$("#ap-err").textContent = "Applying…"
|
|
2469
|
+
try {
|
|
2470
|
+
const out = await api(`/api/projects/${encodeURIComponent(apProject)}/patches/apply`, { method: "POST", body: { repos } })
|
|
2471
|
+
await openApply(apProject)
|
|
2472
|
+
$("#ap-err").innerHTML = `<span style="color:var(--ok)">Done: ${out.results.filter((r) => r.how !== "nothing").map((r) => `${esc(r.rel || "parent")} ${apBadge(r.how, true)}${r.conflicts?.length ? ` (${r.conflicts.map(esc).join(", ")})` : ""}`).join(" · ")}. Review and commit in your repos.</span>`
|
|
2473
|
+
} catch (e) { $("#ap-err").textContent = e.message; $("#ap-go").disabled = false }
|
|
2474
|
+
}
|
|
2475
|
+
$("#ap-undo").onclick = async () => {
|
|
2476
|
+
if (!confirm("Take the cleanly applied changes back out of your repos? Anything you edited on top of them since stops the undo for that repo.")) return
|
|
2477
|
+
try {
|
|
2478
|
+
const out = await api(`/api/projects/${encodeURIComponent(apProject)}/patches/undo`, { method: "POST", body: {} })
|
|
2479
|
+
await openApply(apProject)
|
|
2480
|
+
$("#ap-err").textContent = out.map((r) => `${r.rel || "parent"}: ${r.undone ? "undone" : "kept — " + (r.note ?? "")}`).join(" · ")
|
|
2481
|
+
} catch (e) { $("#ap-err").textContent = e.message }
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
// --- a repo into a running workstream
|
|
2485
|
+
const dRepos = wireDialog("#d-repos", "#ar-cancel")
|
|
2486
|
+
let arProject = null
|
|
2487
|
+
async function openAddRepo(projectId) {
|
|
2488
|
+
arProject = (state.projects ?? []).find((x) => x.id === projectId)
|
|
2489
|
+
if (!arProject) return
|
|
2490
|
+
const ws = currentWs()
|
|
2491
|
+
const lib = ws ? await library(ws.id, true).catch(() => null) : null
|
|
2492
|
+
const inIt = new Set((arProject.services ?? []).map((x) => (typeof x === "string" ? x : x.path)))
|
|
2493
|
+
const known = [...(lib?.nested ?? []).map((n) => ({ value: n.rel, label: n.rel, note: n.branch ?? "" })), ...(lib?.linked ?? []).map((l) => ({ value: l.path, label: l.as + "/", note: l.path, as: l.as }))].filter((k) => !inIt.has(k.value))
|
|
2494
|
+
$("#ar-known-field").hidden = !known.length
|
|
2495
|
+
$("#ar-known").innerHTML = known.map((k) => `<label><input type="radio" name="ar-k" value="${esc(k.value)}" data-as="${esc(k.as ?? "")}" /> ${esc(k.label)} <span class="note">${esc(k.note)}</span></label>`).join("")
|
|
2496
|
+
$("#ar-path").value = ""; $("#ar-as").value = ""; $("#ar-err").textContent = ""
|
|
2497
|
+
dRepos.showModal()
|
|
2498
|
+
}
|
|
2499
|
+
$("#ar-go").onclick = async () => {
|
|
2500
|
+
const pick = $("#ar-known input:checked")
|
|
2501
|
+
const path = $("#ar-path").value.trim() || pick?.value
|
|
2502
|
+
const as = $("#ar-as").value.trim() || (!$("#ar-path").value.trim() ? pick?.dataset.as : "") || undefined
|
|
2503
|
+
if (!path) { $("#ar-err").textContent = "Pick a repo or give a path."; return }
|
|
2504
|
+
$("#ar-go").disabled = true
|
|
2505
|
+
$("#ar-err").textContent = "Making its worktree…"
|
|
2506
|
+
try {
|
|
2507
|
+
const out = await api(`/api/projects/${encodeURIComponent(arProject.id)}/repos`, { method: "POST", body: { path, as } })
|
|
2508
|
+
if ($("#ar-remember").checked && (path.startsWith("/") || path.startsWith("~")) && arProject.workspaceId) {
|
|
2509
|
+
await api(`/api/workspaces/${encodeURIComponent(arProject.workspaceId)}/repos`, { method: "POST", body: { path, as } }).catch(() => {})
|
|
2510
|
+
libs.delete(arProject.workspaceId)
|
|
2511
|
+
}
|
|
2512
|
+
dRepos.close()
|
|
2513
|
+
if (out.warning) alert(out.warning)
|
|
2514
|
+
} catch (err) { $("#ar-err").textContent = err.message }
|
|
2515
|
+
$("#ar-go").disabled = false
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
// --- new workstream
|
|
2519
|
+
const dStream = wireDialog("#d-stream", "#ns-cancel")
|
|
2520
|
+
let branchTouched = false
|
|
2521
|
+
async function openNewStream(pipelineId) {
|
|
2522
|
+
const ws = currentWs()
|
|
2523
|
+
if (!ws) return openFolder()
|
|
2524
|
+
const lib = await library(ws.id, true).catch((e) => { alert(e.message); return null })
|
|
2525
|
+
if (!lib) return
|
|
2526
|
+
$("#ns-err").textContent = lib.problems?.length ? lib.problems.map((p) => `${p.file}${p.id ? " · " + p.id : ""}: ${p.problems.join("; ")}`).join("\n") : ""
|
|
2527
|
+
$("#ns-pipeline").innerHTML = lib.pipelines.map((p) => `<option value="${esc(p.id)}">${esc(p.title ?? p.id)}</option>`).join("")
|
|
2528
|
+
if (pipelineId) $("#ns-pipeline").value = pipelineId
|
|
2529
|
+
showStagePreview()
|
|
2530
|
+
$("#ns-story").value = ""
|
|
2531
|
+
$("#ns-title").value = ""
|
|
2532
|
+
$("#ns-branch").value = ""
|
|
2533
|
+
branchTouched = false
|
|
2534
|
+
paintRepoPicks(ws, lib)
|
|
2535
|
+
$("#ns-add-path").value = ""
|
|
2536
|
+
$("#ns-add-as").value = ""
|
|
2537
|
+
dStream.showModal()
|
|
2538
|
+
$("#ns-story").focus()
|
|
2539
|
+
}
|
|
2540
|
+
function paintRepoPicks(ws, lib) {
|
|
2541
|
+
const nested = lib.nested ?? []
|
|
2542
|
+
const linked = lib.linked ?? ws.linked ?? []
|
|
2543
|
+
$("#ns-svcs").innerHTML = [
|
|
2544
|
+
...nested.map((n) => `<label><input type="checkbox" value="${esc(n.rel)}" ${ws.services?.includes(n.rel) ? "checked" : ""} /> ${esc(n.rel)} <span class="note">${esc(n.branch ?? "")}</span></label>`),
|
|
2545
|
+
...linked.map((l) => `<label><input type="checkbox" value="${esc(l.path)}" ${ws.services?.includes(l.path) ? "checked" : ""} /> ${esc(l.as)}/ <span class="note">${esc(l.path)}</span><button class="sm ghost" data-unlink="${esc(l.path)}" title="Stop offering this repo">×</button></label>`),
|
|
2546
|
+
].join("") || `<span class="note">Only the workspace repo. Add one from anywhere below.</span>`
|
|
2547
|
+
for (const b of $$("[data-unlink]", $("#ns-svcs"))) b.onclick = async (e) => {
|
|
2548
|
+
e.preventDefault()
|
|
2549
|
+
const out = await api(`/api/workspaces/${encodeURIComponent(ws.id)}/repos?path=${encodeURIComponent(b.dataset.unlink)}`, { method: "DELETE" }).catch((err) => { alert(err.message); return null })
|
|
2550
|
+
if (out) { Object.assign(ws, out); paintRepoPicks(ws, { ...lib, linked: out.linked }) }
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
$("#ns-add").onclick = async (e) => {
|
|
2554
|
+
e.preventDefault()
|
|
2555
|
+
const ws = currentWs()
|
|
2556
|
+
const path = $("#ns-add-path").value.trim()
|
|
2557
|
+
if (!ws || !path) return
|
|
2558
|
+
$("#ns-add").disabled = true
|
|
2559
|
+
try {
|
|
2560
|
+
const out = await api(`/api/workspaces/${encodeURIComponent(ws.id)}/repos`, { method: "POST", body: { path, as: $("#ns-add-as").value.trim() || undefined } })
|
|
2561
|
+
Object.assign(ws, out)
|
|
2562
|
+
const lib = libs.get(ws.id) ?? {}
|
|
2563
|
+
lib.linked = out.linked
|
|
2564
|
+
paintRepoPicks(ws, lib)
|
|
2565
|
+
$("#ns-add-path").value = ""; $("#ns-add-as").value = ""
|
|
2566
|
+
$("#ns-err").textContent = ""
|
|
2567
|
+
} catch (err) { $("#ns-err").textContent = err.message }
|
|
2568
|
+
$("#ns-add").disabled = false
|
|
2569
|
+
}
|
|
2570
|
+
function showStagePreview() {
|
|
2571
|
+
const lib = libs.get(ui.ws)
|
|
2572
|
+
const def = lib?.pipelines.find((p) => p.id === $("#ns-pipeline").value)
|
|
2573
|
+
$("#ns-stages").innerHTML = def
|
|
2574
|
+
? def.stages.map((s) => `<span class="chip" title="${esc(s.after ? "starts after " + s.after.join(", ") : "")}">${esc(s.id)} · ${s.human ? "you" : "@" + esc(s.persona)}${s.split ? ` ×${esc(s.parallel ?? 3)}` : ""}${s.after ? " ⇉" : ""}</span>`).join("") + (def.description ? `<div class="note" style="width:100%">${esc(def.description)}</div>` : "")
|
|
2575
|
+
: ""
|
|
2576
|
+
const splits = (def?.stages ?? []).filter((s) => s.split && !s.human)
|
|
2577
|
+
$("#ns-par-field").hidden = !splits.length
|
|
2578
|
+
$("#ns-par").innerHTML = splits.map((s) => `<div class="field"><label for="ns-par-${esc(s.id)}">${esc(s.id)} — max @${esc(s.persona)} bots</label><input id="ns-par-${esc(s.id)}" data-stage="${esc(s.id)}" type="number" min="1" max="10" value="${esc(s.parallel ?? 3)}" /></div>`).join("")
|
|
2579
|
+
$("#ns-limits").innerHTML = limitInputs("nsl", def?.limits ?? {})
|
|
2580
|
+
}
|
|
2581
|
+
$("#ns-pipeline").onchange = showStagePreview
|
|
2582
|
+
const autoBranch = () => { if (!branchTouched) $("#ns-branch").placeholder = $("#ns-branch").value = ($("#ns-title").value.trim() ? "botfarm/" + slug($("#ns-title").value, 36) : "") }
|
|
2583
|
+
$("#ns-story").oninput = () => {
|
|
2584
|
+
if ($("#ns-title").dataset.touched) return
|
|
2585
|
+
const first = $("#ns-story").value.split("\n").map((l) => l.trim()).find(Boolean) ?? ""
|
|
2586
|
+
$("#ns-title").value = first.length > 48 ? first.slice(0, 48).replace(/\s+\S*$/, "") : first
|
|
2587
|
+
autoBranch()
|
|
2588
|
+
}
|
|
2589
|
+
$("#ns-title").oninput = () => { $("#ns-title").dataset.touched = $("#ns-title").value ? "1" : ""; autoBranch() }
|
|
2590
|
+
$("#ns-branch").oninput = () => { branchTouched = !!$("#ns-branch").value }
|
|
2591
|
+
$("#ns-go").onclick = async () => {
|
|
2592
|
+
const services = $$("#ns-svcs input:checked").map((c) => c.value)
|
|
2593
|
+
const parallel = Object.fromEntries($$("#ns-par input").map((i) => [i.dataset.stage, Number(i.value) || 1]))
|
|
2594
|
+
const body = { pipeline: $("#ns-pipeline").value, story: $("#ns-story").value, title: $("#ns-title").value.trim() || undefined, branch: $("#ns-branch").value.trim() || undefined, services, parallel, limits: readLimits("nsl") }
|
|
2595
|
+
$("#ns-go").disabled = true
|
|
2596
|
+
$("#ns-err").textContent = "Creating the worktree and the team…"
|
|
2597
|
+
try {
|
|
2598
|
+
await api(`/api/workspaces/${encodeURIComponent(ui.ws)}`, { method: "POST", body: { services } }).catch(() => {})
|
|
2599
|
+
const run = await api(`/api/workspaces/${encodeURIComponent(ui.ws)}/workstreams`, { method: "POST", body })
|
|
2600
|
+
dStream.close()
|
|
2601
|
+
$("#ns-title").dataset.touched = ""
|
|
2602
|
+
go("stream", run.projectId)
|
|
2603
|
+
} catch (e) { $("#ns-err").textContent = e.message }
|
|
2604
|
+
$("#ns-go").disabled = false
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2607
|
+
// --- new card
|
|
2608
|
+
const dCard = wireDialog("#d-card", "#nc-cancel")
|
|
2609
|
+
function openNewCard(pid) {
|
|
2610
|
+
const p = (state.projects ?? []).find((x) => x.id === pid)
|
|
2611
|
+
const team = teamOf(p)
|
|
2612
|
+
$("#nc-title").value = ""
|
|
2613
|
+
$("#nc-brief").value = ""
|
|
2614
|
+
$("#nc-err").textContent = ""
|
|
2615
|
+
$("#nc-who").innerHTML = team.map((s) => `<option value="s:${esc(s.id)}">@${esc(s.handle)} (${esc(s.role ?? s.persona ?? "bot")})</option>`).join("") +
|
|
2616
|
+
[...new Set(team.map((s) => s.role).filter(Boolean))].map((r) => `<option value="r:${esc(r)}">whoever plays ${esc(r)}</option>`).join("") +
|
|
2617
|
+
`<option value="h:">me</option>`
|
|
2618
|
+
dCard.dataset.p = pid
|
|
2619
|
+
dCard.showModal()
|
|
2620
|
+
$("#nc-title").focus()
|
|
2621
|
+
}
|
|
2622
|
+
$("#nc-go").onclick = async () => {
|
|
2623
|
+
const [kind, v] = $("#nc-who").value.split(/:(.*)/)
|
|
2624
|
+
const body = { title: $("#nc-title").value, brief: $("#nc-brief").value, projectId: dCard.dataset.p }
|
|
2625
|
+
if (kind === "s") body.assignee = v
|
|
2626
|
+
if (kind === "r") { body.role = v; body.owner = "role" }
|
|
2627
|
+
if (kind === "h") body.owner = "human"
|
|
2628
|
+
try { await api("/api/tasks", { method: "POST", body }); dCard.close() } catch (e) { $("#nc-err").textContent = e.message }
|
|
2629
|
+
}
|
|
2630
|
+
|
|
2631
|
+
// --- prices for models the table does not know
|
|
2632
|
+
const dPrice = wireDialog("#d-price", "#pr-cancel")
|
|
2633
|
+
$("#f-spend").onclick = async () => {
|
|
2634
|
+
const v = await api("/api/pricing").catch(() => null)
|
|
2635
|
+
if (!v) return
|
|
2636
|
+
const rows = [...new Set([...(v.unknown ?? []), ...Object.keys(v.overrides ?? {})])]
|
|
2637
|
+
$("#pr-err").textContent = ""
|
|
2638
|
+
$("#pr-rows").innerHTML = rows.length
|
|
2639
|
+
? rows.map((m) => {
|
|
2640
|
+
const o = v.overrides?.[m] ?? {}
|
|
2641
|
+
return `<div class="row" data-m="${esc(m)}" style="align-items:flex-end">
|
|
2642
|
+
<div class="field"><label>${v.unknown?.includes(m) ? `<span style="color:var(--busy)">no price</span> · ` : ""}model</label><input class="mono" value="${esc(m)}" disabled /></div>
|
|
2643
|
+
<div class="field" style="max-width:110px"><label>input $/M</label><input data-k="input" type="number" min="0" step="0.01" value="${o.input ?? ""}" /></div>
|
|
2644
|
+
<div class="field" style="max-width:110px"><label>output $/M</label><input data-k="output" type="number" min="0" step="0.01" value="${o.output ?? ""}" /></div></div>`
|
|
2645
|
+
}).join("")
|
|
2646
|
+
: `<div class="empty">Every model in use has a price.</div>`
|
|
2647
|
+
dPrice.showModal()
|
|
2648
|
+
}
|
|
2649
|
+
$("#pr-save").onclick = async () => {
|
|
2650
|
+
try {
|
|
2651
|
+
for (const row of $$("#pr-rows [data-m]")) {
|
|
2652
|
+
const input = $("[data-k=input]", row).value, output = $("[data-k=output]", row).value
|
|
2653
|
+
if (input === "" || output === "") continue
|
|
2654
|
+
await api("/api/pricing", { method: "PUT", body: { model: row.dataset.m, input: Number(input), output: Number(output) } })
|
|
2655
|
+
}
|
|
2656
|
+
dPrice.close()
|
|
2657
|
+
} catch (e) { $("#pr-err").textContent = e.message }
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2660
|
+
// --- restart a workstream from a stage
|
|
2661
|
+
const dRestart = wireDialog("#d-restart", "#rs-cancel")
|
|
2662
|
+
function openRestart(pid, stage = null) {
|
|
2663
|
+
const p = (state.projects ?? []).find((x) => x.id === pid)
|
|
2664
|
+
if (!p) return
|
|
2665
|
+
const tasks = tasksOf(p).filter((t) => t.stage)
|
|
2666
|
+
const firstOpen = tasks.find((t) => t.status !== "done")?.stage ?? tasks[0]?.stage
|
|
2667
|
+
$("#rs-stage").innerHTML = tasks.map((t) => `<option value="${esc(t.stage)}" ${t.stage === (stage ?? firstOpen) ? "selected" : ""}>${esc(t.stage)} — ${esc(t.title)} (${esc(t.status)})</option>`).join("")
|
|
2668
|
+
$("#rs-err").textContent = ""
|
|
2669
|
+
dRestart.dataset.p = pid
|
|
2670
|
+
dRestart.showModal()
|
|
2671
|
+
}
|
|
2672
|
+
$("#rs-go").onclick = async () => {
|
|
2673
|
+
$("#rs-go").disabled = true
|
|
2674
|
+
$("#rs-err").textContent = $("#rs-fresh").checked ? "Starting fresh bots…" : ""
|
|
2675
|
+
try {
|
|
2676
|
+
await api(`/api/projects/${encodeURIComponent(dRestart.dataset.p)}/restart`, { method: "POST", body: { stage: $("#rs-stage").value, fresh: $("#rs-fresh").checked } })
|
|
2677
|
+
dRestart.close()
|
|
2678
|
+
} catch (e) { $("#rs-err").textContent = e.message }
|
|
2679
|
+
$("#rs-go").disabled = false
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2682
|
+
// --- delete workstream
|
|
2683
|
+
const dDelete = wireDialog("#d-delete", "#dl-cancel")
|
|
2684
|
+
async function openDelete(pid) {
|
|
2685
|
+
let pv
|
|
2686
|
+
try { pv = await api(`/api/projects/${encodeURIComponent(pid)}/delete`) } catch (e) { return alert(e.message) }
|
|
2687
|
+
dDelete.dataset.p = pid
|
|
2688
|
+
$("#dl-name").textContent = pv.name
|
|
2689
|
+
const parts = [
|
|
2690
|
+
`${pv.bots} bot${pv.bots === 1 ? "" : "s"} and their opencode sessions`,
|
|
2691
|
+
`${pv.cards} card${pv.cards === 1 ? "" : "s"}`,
|
|
2692
|
+
`${pv.messages} chat message${pv.messages === 1 ? "" : "s"}`,
|
|
2693
|
+
pv.worktree ? `the worktree at ${fmt.path(pv.worktree)}` : null,
|
|
2694
|
+
].filter(Boolean)
|
|
2695
|
+
$("#dl-what").textContent = `This removes ${parts.join(", ")}. It cannot be undone.`
|
|
2696
|
+
$("#dl-dirty").hidden = !pv.changed
|
|
2697
|
+
$("#dl-force").checked = false
|
|
2698
|
+
if (pv.changed) {
|
|
2699
|
+
$("#dl-dirty-head").textContent = `${pv.changed} uncommitted file${pv.changed > 1 ? "s" : ""} in the worktree will be lost`
|
|
2700
|
+
$("#dl-files").innerHTML = pv.files.map(esc).join("<br>") + (pv.changed > pv.files.length ? `<br>… and ${pv.changed - pv.files.length} more` : "")
|
|
2701
|
+
}
|
|
2702
|
+
$("#dl-branch-row").hidden = !pv.branch
|
|
2703
|
+
$("#dl-branch-name").textContent = pv.branch ?? ""
|
|
2704
|
+
$("#dl-branch").checked = false
|
|
2705
|
+
$("#dl-branch").disabled = !pv.branchOwned
|
|
2706
|
+
$("#dl-branch-note").textContent = !pv.branchOwned
|
|
2707
|
+
? "Kept: BotFarm only deletes branches it named (botfarm/…)."
|
|
2708
|
+
: pv.commits
|
|
2709
|
+
? `Careful: ${pv.commits} commit${pv.commits > 1 ? "s" : ""} on it are on no other branch or remote, and would be gone.`
|
|
2710
|
+
: "Everything on it is already on another branch or a remote. Unticked, the branch stays for a PR."
|
|
2711
|
+
$("#dl-err").textContent = ""
|
|
2712
|
+
const sync = () => { $("#dl-go").disabled = !!pv.changed && !$("#dl-force").checked }
|
|
2713
|
+
$("#dl-force").onchange = sync
|
|
2714
|
+
sync()
|
|
2715
|
+
dDelete.showModal()
|
|
2716
|
+
}
|
|
2717
|
+
$("#dl-go").onclick = async () => {
|
|
2718
|
+
$("#dl-go").disabled = true
|
|
2719
|
+
$("#dl-err").textContent = "Deleting…"
|
|
2720
|
+
try {
|
|
2721
|
+
await api(`/api/projects/${encodeURIComponent(dDelete.dataset.p)}/delete`, { method: "POST", body: { force: $("#dl-force").checked, deleteBranch: $("#dl-branch").checked } })
|
|
2722
|
+
dDelete.close()
|
|
2723
|
+
go("home")
|
|
2724
|
+
} catch (e) { $("#dl-err").textContent = e.message }
|
|
2725
|
+
$("#dl-go").disabled = false
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
// --- add bot
|
|
2729
|
+
const dBot = wireDialog("#d-bot", "#nb-cancel")
|
|
2730
|
+
async function openAddBot(pid) {
|
|
2731
|
+
const lib = await library(ui.ws)
|
|
2732
|
+
$("#nb-agent").innerHTML = (lib?.agents ?? []).map((a) => `<option value="${esc(a.id)}">${esc(a.title ?? a.id)}</option>`).join("")
|
|
2733
|
+
$("#nb-err").textContent = ""
|
|
2734
|
+
dBot.dataset.p = pid
|
|
2735
|
+
dBot.showModal()
|
|
2736
|
+
}
|
|
2737
|
+
$("#nb-go").onclick = async () => {
|
|
2738
|
+
$("#nb-go").disabled = true
|
|
2739
|
+
try { await api(`/api/projects/${encodeURIComponent(dBot.dataset.p)}/bots`, { method: "POST", body: { agent: $("#nb-agent").value } }); dBot.close() }
|
|
2740
|
+
catch (e) { $("#nb-err").textContent = e.message }
|
|
2741
|
+
$("#nb-go").disabled = false
|
|
2742
|
+
}
|
|
2743
|
+
|
|
2744
|
+
// ------------------------------------------------------------------ pipeline editor
|
|
2745
|
+
const NEW_STAGE_PROMPT = `Original story:
|
|
2746
|
+
|
|
2747
|
+
{{story}}
|
|
2748
|
+
|
|
2749
|
+
{{#received}}
|
|
2750
|
+
--- {{persona}} ({{stage}}) ---
|
|
2751
|
+
{{summary}}
|
|
2752
|
+
{{#acceptance_criteria}} - {{.}}
|
|
2753
|
+
{{/acceptance_criteria}}
|
|
2754
|
+
{{/received}}
|
|
2755
|
+
`
|
|
2756
|
+
let ed = null // { kind, id, doc, isNew, tab }
|
|
2757
|
+
async function renderPipeline(key) {
|
|
2758
|
+
mainKey = key
|
|
2759
|
+
const lib = await library(ui.ws, true).catch(() => null)
|
|
2760
|
+
if (!lib) return
|
|
2761
|
+
const isNew = ui.id === "+new"
|
|
2762
|
+
const found = lib.pipelines.find((p) => p.id === ui.id)
|
|
2763
|
+
if (!isNew && !found) { $("#main").innerHTML = `<div class="page"><div class="empty">No pipeline "${esc(ui.id)}" in this workspace.</div></div>`; return }
|
|
2764
|
+
const doc = isNew
|
|
2765
|
+
? { id: "", title: "", description: "", stages: [{ id: "build", persona: lib.agents[0]?.id ?? "", title: "", receives: [], prompt: "{{story}}\n" }] }
|
|
2766
|
+
: JSON.parse(JSON.stringify(found))
|
|
2767
|
+
ed = { kind: "pipelines", id: isNew ? null : found.id, doc, isNew, tab: "form", lib }
|
|
2768
|
+
paintPipeline()
|
|
2769
|
+
}
|
|
2770
|
+
function paintPipeline() {
|
|
2771
|
+
const { doc, lib, isNew } = ed
|
|
2772
|
+
const problems = (lib.problems ?? []).filter((p) => /\.pipeline\.botfarm\.ya?ml$/.test(p.file) && (!p.id || p.id === ed.id))
|
|
2773
|
+
$("#main").innerHTML = `<div class="page">
|
|
2774
|
+
<div class="edhead"><h1>${isNew ? "New pipeline" : esc(doc.title || doc.id)}</h1>
|
|
2775
|
+
<div class="acts">${!isNew ? `<button class="sm" id="pe-run">Start a workstream with it</button><button class="sm danger" id="pe-del">Delete</button>` : ""}
|
|
2776
|
+
<button class="primary sm" id="pe-save">Save</button></div></div>
|
|
2777
|
+
<div class="note mono">${esc(isNew ? "botfarm/<id>.pipeline.botfarm.yml" : doc.file ?? "")}</div>
|
|
2778
|
+
<div class="tabs"><button class="sm ghost" data-tab="form" aria-pressed="${ed.tab === "form"}">Stages</button><button class="sm ghost" data-tab="yaml" aria-pressed="${ed.tab === "yaml"}">This file (YAML)</button></div>
|
|
2779
|
+
${problems.length ? `<div class="problems">${problems.map((p) => esc(p.problems.join("; "))).join("<br>")}</div>` : ""}
|
|
2780
|
+
<div class="err" id="pe-err"></div>
|
|
2781
|
+
<div id="pe-body"></div></div>`
|
|
2782
|
+
for (const b of $$("[data-tab]")) b.onclick = () => { if (ed.tab === "form") readPipelineForm(); ed.tab = b.dataset.tab; paintPipeline() }
|
|
2783
|
+
$("#pe-save").onclick = savePipeline
|
|
2784
|
+
if ($("#pe-run")) $("#pe-run").onclick = () => openNewStream(ed.id)
|
|
2785
|
+
if ($("#pe-del")) $("#pe-del").onclick = async () => {
|
|
2786
|
+
if (!confirm(`Delete pipeline "${ed.id}"? This deletes botfarm/${ed.id}.pipeline.botfarm.yml.`)) return
|
|
2787
|
+
await api(`/api/workspaces/${encodeURIComponent(ui.ws)}/pipelines/${encodeURIComponent(ed.id)}`, { method: "DELETE" })
|
|
2788
|
+
libs.delete(ui.ws)
|
|
2789
|
+
go("home")
|
|
2790
|
+
}
|
|
2791
|
+
if (ed.tab === "yaml") return paintRaw("pipelines")
|
|
2792
|
+
const agents = lib.agents ?? []
|
|
2793
|
+
$("#pe-body").innerHTML = `
|
|
2794
|
+
<div class="row" style="max-width:900px">
|
|
2795
|
+
<div class="field"><label>Name</label><input id="pe-title" value="${esc(doc.title ?? "")}" placeholder="Do a user story" /></div>
|
|
2796
|
+
<div class="field" style="max-width:220px"><label>Id</label><input id="pe-id" class="mono" value="${esc(doc.id ?? "")}" ${isNew ? "" : "disabled"} placeholder="do-user-story" /></div>
|
|
2797
|
+
</div>
|
|
2798
|
+
<div class="field" style="max-width:900px"><label>Description</label><input id="pe-desc" value="${esc(doc.description ?? "")}" /></div>
|
|
2799
|
+
<div class="note" style="margin:4px 0 10px;max-width:900px">Each stage is one card on the board, handed to one agent (or to you). A stage starts when the one before it hands off — or when the stages it is set to start after have. A split stage becomes one card per item of the list handed to it, worked by several bots at once; the stages after it wait for every piece.
|
|
2800
|
+
In prompts: <span class="mono">{{story}}</span>, <span class="mono">{{#handoffs.analyse}}…{{/handoffs.analyse}}</span> for one earlier stage,
|
|
2801
|
+
<span class="mono">{{#received}}…{{/received}}</span> for all it receives, with <span class="mono">{{summary}}</span>, <span class="mono">{{#acceptance_criteria}}{{.}}{{/acceptance_criteria}}</span>, <span class="mono">{{#artifacts}}</span>.</div>
|
|
2802
|
+
<div class="stages">${doc.stages.map((s, i) => `
|
|
2803
|
+
${i ? `<div class="link">↓</div>` : ""}
|
|
2804
|
+
<div class="stagecard ${s.human ? "human" : ""}" data-i="${i}">
|
|
2805
|
+
<div class="sh"><span class="n">${i + 1}</span>
|
|
2806
|
+
<input data-f="id" class="mono" value="${esc(s.id ?? "")}" placeholder="stage id" style="width:150px" />
|
|
2807
|
+
<input data-f="title" value="${esc(s.title ?? "")}" placeholder="What the card says" style="flex:1" />
|
|
2808
|
+
<span class="tools"><button class="sm ghost" data-up ${i ? "" : "disabled"} title="Move up">↑</button><button class="sm ghost" data-down ${i < doc.stages.length - 1 ? "" : "disabled"} title="Move down">↓</button><button class="sm ghost danger" data-del title="Remove stage">✕</button></span></div>
|
|
2809
|
+
<div class="row">
|
|
2810
|
+
<div class="field" style="max-width:260px"><label>Done by</label><select data-f="persona">
|
|
2811
|
+
${agents.map((a) => `<option value="${esc(a.id)}" ${!s.human && s.persona === a.id ? "selected" : ""}>@${esc(a.id)} — ${esc(a.title ?? "")}</option>`).join("")}
|
|
2812
|
+
<option value="__human" ${s.human ? "selected" : ""}>you (sign-off / decision)</option></select></div>
|
|
2813
|
+
${s.human ? "" : `<div class="field" style="max-width:190px"><label>If it has open questions</label><select data-f="hold">
|
|
2814
|
+
<option value="">hand off anyway</option><option value="1" ${s.hold_on_questions ? "selected" : ""}>wait for me</option></select></div>
|
|
2815
|
+
<div class="field" style="max-width:170px"><label title="Which model tier this stage's cards get, when model routing is on">Difficulty</label><select data-f="difficulty">
|
|
2816
|
+
${[["", "auto"], ["easy", "easy"], ["normal", "normal"], ["hard", "hard"]].map(([v, l]) => `<option value="${v}" ${(s.difficulty ?? "") === v ? "selected" : ""}>${l}</option>`).join("")}</select></div>
|
|
2817
|
+
${i ? `<div class="field" style="max-width:190px"><label title="How many times this stage may send earlier work back to be fixed before it asks you. 0: never.">Fix rounds (send back)</label><select data-f="rounds">
|
|
2818
|
+
${[["", "default (3)"], ["0", "never send back"], ["1", "1"], ["2", "2"], ["3", "3"], ["5", "5"], ["8", "8"]].map(([v, l]) => `<option value="${v}" ${String(s.send_back === false ? 0 : s.max_rounds ?? "") === v ? "selected" : ""}>${l}</option>`).join("")}</select></div>` : ""}`}
|
|
2819
|
+
<div class="field"><label>Receives handoffs from</label><div class="toggles">${doc.stages.slice(0, i).map((e) => `<button class="toggle" data-recv="${esc(e.id)}" aria-pressed="${(s.receives ?? []).includes(e.id)}">${esc(e.id)}</button>`).join("") || `<span class="note">first stage — gets the story</span>`}</div></div>
|
|
2820
|
+
</div>
|
|
2821
|
+
${i ? `<div class="row">
|
|
2822
|
+
<div class="field"><label>Starts after <span class="note">(nothing picked: the stage before it)</span></label><div class="toggles">${doc.stages.slice(0, i).map((e) => `<button class="toggle" data-after="${esc(e.id)}" aria-pressed="${(s.after ?? []).includes(e.id)}">${esc(e.id)}</button>`).join("")}</div>
|
|
2823
|
+
<div class="hint">Pick an earlier stage to run side by side with the ones in between — e.g. QA writing tests while dev builds.</div></div>
|
|
2824
|
+
${s.human ? "" : `<div class="field" style="max-width:250px"><label>Split into parallel cards</label><select data-f="split">
|
|
2825
|
+
<option value="">no — one card</option><option value="tasks" ${s.split ? "selected" : ""}>yes — one per item in the "tasks" list</option></select>
|
|
2826
|
+
<div class="hint">The stage it waits on is asked for the list.</div></div>
|
|
2827
|
+
<div class="field" style="max-width:130px"><label>Max bots at once</label><input data-f="parallel" type="number" min="1" max="10" value="${esc(s.parallel ?? (s.split ? 3 : ""))}" ${s.split ? "" : "disabled"} placeholder="3" /></div>`}
|
|
2828
|
+
</div>` : ""}
|
|
2829
|
+
${s.human ? "" : `<div class="field"><label>Limits per card <span class="note">(optional — the card stops and asks you when it reaches one)</span></label>${limitInputs("pl" + i, s.limits ?? {})}</div>`}
|
|
2830
|
+
<div class="field"><label>Prompt</label><textarea data-f="prompt" class="code" rows="${Math.min(14, Math.max(4, (s.prompt ?? "").split("\n").length + 1))}">${esc(s.prompt ?? "")}</textarea></div>
|
|
2831
|
+
</div>`).join("")}
|
|
2832
|
+
<div class="link">↓</div>
|
|
2833
|
+
<button id="pe-add" style="align-self:flex-start">+ Add a stage</button>
|
|
2834
|
+
</div>`
|
|
2835
|
+
for (const card of $$(".stagecard")) {
|
|
2836
|
+
const i = Number(card.dataset.i)
|
|
2837
|
+
const move = (d) => { readPipelineForm(); const [x] = doc.stages.splice(i, 1); doc.stages.splice(i + d, 0, x); paintPipeline() }
|
|
2838
|
+
$("[data-up]", card).onclick = () => move(-1)
|
|
2839
|
+
$("[data-down]", card).onclick = () => move(1)
|
|
2840
|
+
$("[data-del]", card).onclick = () => { readPipelineForm(); const [gone] = doc.stages.splice(i, 1); for (const s of doc.stages) s.receives = (s.receives ?? []).filter((r) => r !== gone.id); paintPipeline() }
|
|
2841
|
+
for (const b of $$("[data-recv], [data-after]", card)) b.onclick = () => b.setAttribute("aria-pressed", String(b.getAttribute("aria-pressed") !== "true"))
|
|
2842
|
+
if ($("[data-f=split]", card)) $("[data-f=split]", card).onchange = () => { readPipelineForm(); paintPipeline() }
|
|
2843
|
+
$("[data-f=persona]", card).onchange = () => { readPipelineForm(); paintPipeline() }
|
|
2844
|
+
}
|
|
2845
|
+
$("#pe-add").onclick = () => {
|
|
2846
|
+
readPipelineForm()
|
|
2847
|
+
const prev = doc.stages.map((s) => s.id).filter(Boolean)
|
|
2848
|
+
doc.stages.push({ id: `stage${doc.stages.length + 1}`, persona: agents[0]?.id ?? "", title: "", receives: prev.slice(-1), prompt: NEW_STAGE_PROMPT })
|
|
2849
|
+
paintPipeline()
|
|
2850
|
+
}
|
|
2851
|
+
}
|
|
2852
|
+
function readPipelineForm() {
|
|
2853
|
+
if (!$("#pe-title")) return
|
|
2854
|
+
const d = ed.doc
|
|
2855
|
+
d.title = $("#pe-title").value.trim()
|
|
2856
|
+
if (ed.isNew) d.id = $("#pe-id").value.trim() || slug(d.title)
|
|
2857
|
+
d.description = $("#pe-desc").value.trim()
|
|
2858
|
+
for (const card of $$(".stagecard")) {
|
|
2859
|
+
const s = d.stages[Number(card.dataset.i)]
|
|
2860
|
+
s.id = $("[data-f=id]", card).value.trim()
|
|
2861
|
+
s.title = $("[data-f=title]", card).value.trim() || undefined
|
|
2862
|
+
const who = $("[data-f=persona]", card).value
|
|
2863
|
+
if (who === "__human") { s.human = true; delete s.persona } else { s.persona = who; delete s.human }
|
|
2864
|
+
const hold = $("[data-f=hold]", card)
|
|
2865
|
+
if (hold?.value) s.hold_on_questions = true
|
|
2866
|
+
else delete s.hold_on_questions
|
|
2867
|
+
const diff = $("[data-f=difficulty]", card)?.value
|
|
2868
|
+
if (diff && !s.human) s.difficulty = diff
|
|
2869
|
+
else delete s.difficulty
|
|
2870
|
+
const rounds = $("[data-f=rounds]", card)?.value
|
|
2871
|
+
if (rounds !== undefined) { if (s.send_back === false) delete s.send_back; if (rounds === "" || s.human) delete s.max_rounds; else s.max_rounds = Number(rounds) }
|
|
2872
|
+
s.receives = $$("[data-recv]", card).filter((b) => b.getAttribute("aria-pressed") === "true").map((b) => b.dataset.recv)
|
|
2873
|
+
const after = $$("[data-after]", card).filter((b) => b.getAttribute("aria-pressed") === "true").map((b) => b.dataset.after)
|
|
2874
|
+
if (after.length) s.after = after; else delete s.after
|
|
2875
|
+
const split = $("[data-f=split]", card)?.value
|
|
2876
|
+
if (split && !s.human) s.split = split; else delete s.split
|
|
2877
|
+
const par = Number($("[data-f=parallel]", card)?.value)
|
|
2878
|
+
if (s.split && par >= 1) s.parallel = Math.min(10, par); else delete s.parallel
|
|
2879
|
+
const lim = s.human ? null : readLimits("pl" + card.dataset.i)
|
|
2880
|
+
if (lim) s.limits = lim; else delete s.limits
|
|
2881
|
+
s.prompt = $("[data-f=prompt]", card).value
|
|
2882
|
+
}
|
|
2883
|
+
}
|
|
2884
|
+
async function savePipeline() {
|
|
2885
|
+
if (ed.tab === "yaml") return saveRaw("pipelines")
|
|
2886
|
+
readPipelineForm()
|
|
2887
|
+
const d = ed.doc
|
|
2888
|
+
if (!d.id) return ($("#pe-err").textContent = "Give it a name or an id.")
|
|
2889
|
+
const clean = { ...d, stages: d.stages.map((s) => Object.fromEntries(Object.entries(s).filter(([, v]) => v !== undefined && v !== ""))) }
|
|
2890
|
+
if (!clean.description) delete clean.description
|
|
2891
|
+
try {
|
|
2892
|
+
const lib = await api(`/api/workspaces/${encodeURIComponent(ui.ws)}/pipelines/${encodeURIComponent(d.id)}`, { method: "PUT", body: { doc: clean } })
|
|
2893
|
+
libs.set(ui.ws, { ...libs.get(ui.ws), ...lib })
|
|
2894
|
+
navSig = null
|
|
2895
|
+
if (ed.isNew) return go("pipeline", d.id)
|
|
2896
|
+
ed.lib = libs.get(ui.ws)
|
|
2897
|
+
paintPipeline()
|
|
2898
|
+
$("#pe-err").innerHTML = `<span style="color:var(--ok)">Saved to ${esc(ed.doc.file ?? "botfarm/" + d.id + ".pipeline.botfarm.yml")}.</span>`
|
|
2899
|
+
} catch (e) { $("#pe-err").textContent = e.message }
|
|
2900
|
+
}
|
|
2901
|
+
|
|
2902
|
+
function paintRaw(kind) { return paintRawInto("#pe-body", kind) }
|
|
2903
|
+
async function saveRaw(kind) {
|
|
2904
|
+
const errEl = $("#pe-err") ?? $("#ae-err")
|
|
2905
|
+
try {
|
|
2906
|
+
if (!ed.id) throw new Error("Save it from the form first — the file is named after its id.")
|
|
2907
|
+
const lib = await api(`/api/workspaces/${encodeURIComponent(ui.ws)}/file/${kind}?entry=${encodeURIComponent(ed.id)}`, { method: "PUT", body: { text: $("#raw").value } })
|
|
2908
|
+
libs.set(ui.ws, { ...libs.get(ui.ws), ...lib })
|
|
2909
|
+
navSig = null
|
|
2910
|
+
renderSide()
|
|
2911
|
+
errEl.innerHTML = `<span style="color:var(--ok)">Saved.</span>`
|
|
2912
|
+
} catch (e) { errEl.textContent = e.message }
|
|
2913
|
+
}
|
|
2914
|
+
|
|
2915
|
+
// ------------------------------------------------------------------ tool bench
|
|
2916
|
+
// A workspace tool (botfarm/mcps/<file>): its form, run it where a bot would,
|
|
2917
|
+
// see the result, and edit the source — saving reloads it for every bot.
|
|
2918
|
+
let bench = null
|
|
2919
|
+
function schemaField(key, sch, req, val) {
|
|
2920
|
+
const id = `tb-a-${key}`
|
|
2921
|
+
const label = `<label for="${id}">${esc(key)}${req ? " *" : ""} <span class="note">${esc(sch.type ?? "any")}</span></label>`
|
|
2922
|
+
const hint = sch.description ? `<div class="hint">${esc(sch.description)}</div>` : ""
|
|
2923
|
+
if (Array.isArray(sch.enum)) return `<div class="field">${label}<select id="${id}" data-arg="${esc(key)}" data-t="enum"><option value="">—</option>${sch.enum.map((v) => `<option ${String(val) === String(v) ? "selected" : ""}>${esc(v)}</option>`).join("")}</select>${hint}</div>`
|
|
2924
|
+
if (sch.type === "boolean") return `<div class="field"><label style="display:flex;gap:8px;align-items:center"><input type="checkbox" id="${id}" data-arg="${esc(key)}" data-t="boolean" ${val ? "checked" : ""} /> ${esc(key)}</label>${hint}</div>`
|
|
2925
|
+
if (sch.type === "number" || sch.type === "integer") return `<div class="field">${label}<input id="${id}" type="number" data-arg="${esc(key)}" data-t="number" value="${esc(val ?? sch.default ?? "")}" />${hint}</div>`
|
|
2926
|
+
if (sch.type === "object" || sch.type === "array") return `<div class="field">${label}<textarea id="${id}" class="code" rows="3" data-arg="${esc(key)}" data-t="json" placeholder="${sch.type === "array" ? "[ ]" : "{ }"}">${val !== undefined ? esc(JSON.stringify(val, null, 2)) : ""}</textarea>${hint}</div>`
|
|
2927
|
+
return `<div class="field">${label}<input id="${id}" class="mono" data-arg="${esc(key)}" data-t="string" value="${esc(val ?? sch.default ?? "")}" />${hint}</div>`
|
|
2928
|
+
}
|
|
2929
|
+
function readBenchArgs() {
|
|
2930
|
+
if (bench.raw) return JSON.parse($("#tb-raw").value || "{}")
|
|
2931
|
+
const out = {}
|
|
2932
|
+
for (const el of $$("[data-arg]")) {
|
|
2933
|
+
const k = el.dataset.arg, t = el.dataset.t
|
|
2934
|
+
if (t === "boolean") { if (el.checked) out[k] = true; continue }
|
|
2935
|
+
const v = el.value.trim()
|
|
2936
|
+
if (!v) continue
|
|
2937
|
+
out[k] = t === "number" ? Number(v) : t === "json" ? JSON.parse(v) : v
|
|
2938
|
+
}
|
|
2939
|
+
return out
|
|
2940
|
+
}
|
|
2941
|
+
async function renderTool(key) {
|
|
2942
|
+
const wt = state.tools?.[ui.ws]
|
|
2943
|
+
const tool = wt?.tools?.find((t) => t.file === ui.id)
|
|
2944
|
+
const broken = wt?.errors?.find((e) => e.file === ui.id)
|
|
2945
|
+
const sig = JSON.stringify([key, tool?.name, tool?.description, tool?.inputSchema, broken?.error])
|
|
2946
|
+
if (mainKey === key && bench?.sig === sig) return
|
|
2947
|
+
const fresh = mainKey !== key
|
|
2948
|
+
mainKey = key
|
|
2949
|
+
if (fresh) bench = { file: ui.id, raw: false, args: {}, out: null, source: null }
|
|
2950
|
+
bench.sig = sig
|
|
2951
|
+
if (fresh || bench.source === null) {
|
|
2952
|
+
const src = await api(`/api/workspaces/${encodeURIComponent(ui.ws)}/tools/${encodeURIComponent(ui.id)}/source`).catch(() => ({ text: "", path: "" }))
|
|
2953
|
+
bench.source = src.text
|
|
2954
|
+
bench.path = src.path
|
|
2955
|
+
}
|
|
2956
|
+
const ws = currentWs()
|
|
2957
|
+
const dirs = [{ dir: ws.path, label: `workspace — ${fmt.path(ws.path)}` }, ...streamsOf(ws.id).filter((p) => p.worktree && p.status !== "archived").map((p) => ({ dir: p.worktree, label: `${p.name} — ${fmt.path(p.worktree)}` }))]
|
|
2958
|
+
const props = tool?.inputSchema?.properties ?? {}
|
|
2959
|
+
const req = new Set(tool?.inputSchema?.required ?? [])
|
|
2960
|
+
$("#main").innerHTML = `<div class="page bench">
|
|
2961
|
+
<div class="edhead"><h1 class="mono">${esc(tool ? "botfarm_" + tool.name : ui.id)}</h1>
|
|
2962
|
+
<div class="acts"><button class="sm danger" id="tb-del">Delete file</button></div></div>
|
|
2963
|
+
<div class="note mono">${esc(fmt.path(bench.path || ""))}</div>
|
|
2964
|
+
${broken ? `<div class="problems">This file does not load: ${esc(broken.error)}</div>` : ""}
|
|
2965
|
+
<div class="benchgrid">
|
|
2966
|
+
<section class="panel">
|
|
2967
|
+
<h3>Try it</h3>
|
|
2968
|
+
${tool ? `<p class="lead" style="margin:0 0 12px">${esc(tool.description)}</p>
|
|
2969
|
+
<div class="field"><label for="tb-dir">Run in</label><select id="tb-dir">${dirs.map((d) => `<option value="${esc(d.dir)}" ${bench.dir === d.dir ? "selected" : ""}>${esc(d.label)}</option>`).join("")}</select>
|
|
2970
|
+
<div class="hint">ctx.repoRoot — where a bot's call runs is its own worktree.</div></div>
|
|
2971
|
+
<div class="tabs"><button class="sm ghost" data-mode="form" aria-pressed="${!bench.raw}">Form</button><button class="sm ghost" data-mode="raw" aria-pressed="${bench.raw}">JSON</button></div>
|
|
2972
|
+
<div id="tb-args">${bench.raw
|
|
2973
|
+
? `<textarea id="tb-raw" class="code" rows="8" style="width:100%">${esc(JSON.stringify(bench.args ?? {}, null, 2))}</textarea>`
|
|
2974
|
+
: Object.keys(props).length ? Object.entries(props).map(([k, sch]) => schemaField(k, sch, req.has(k), bench.args?.[k])).join("") : `<div class="note">No arguments.</div>`}</div>
|
|
2975
|
+
<div class="askf" style="display:flex;gap:8px;align-items:center"><span class="note" id="tb-note" style="margin-right:auto"></span><button class="primary" id="tb-run">Run</button></div>
|
|
2976
|
+
<div id="tb-out">${benchOut()}</div>` : `<div class="note">Fix the file and save to try it.</div>`}
|
|
2977
|
+
</section>
|
|
2978
|
+
<section class="panel">
|
|
2979
|
+
<h3>Source <span class="note">— saving reloads it for every bot in this workspace</span></h3>
|
|
2980
|
+
<textarea id="tb-src" class="code" spellcheck="false" style="width:100%;min-height:52vh">${esc(bench.source ?? "")}</textarea>
|
|
2981
|
+
<div style="display:flex;gap:8px;align-items:center;margin-top:8px"><span class="err" id="tb-err" style="margin-right:auto"></span><button class="primary sm" id="tb-save">Save</button></div>
|
|
2982
|
+
</section>
|
|
2983
|
+
</div></div>`
|
|
2984
|
+
for (const b of $$("[data-mode]")) b.onclick = () => { try { bench.args = readBenchArgs() } catch {} bench.raw = b.dataset.mode === "raw"; mainKey = null; renderTool(key) }
|
|
2985
|
+
if ($("#tb-dir")) $("#tb-dir").onchange = (e) => { bench.dir = e.target.value }
|
|
2986
|
+
if ($("#tb-run")) $("#tb-run").onclick = async () => {
|
|
2987
|
+
let args
|
|
2988
|
+
try { args = readBenchArgs() } catch (e) { $("#tb-note").textContent = "Arguments are not valid JSON: " + e.message; return }
|
|
2989
|
+
bench.args = args
|
|
2990
|
+
$("#tb-run").disabled = true
|
|
2991
|
+
$("#tb-note").textContent = "Running…"
|
|
2992
|
+
try { bench.out = await api(`/api/workspaces/${encodeURIComponent(ui.ws)}/tools/${encodeURIComponent(tool.name)}/run`, { method: "POST", body: { args, directory: $("#tb-dir").value } }) }
|
|
2993
|
+
catch (e) { bench.out = { ok: false, error: e.message, ms: 0, logs: [] } }
|
|
2994
|
+
$("#tb-run").disabled = false
|
|
2995
|
+
$("#tb-note").textContent = ""
|
|
2996
|
+
$("#tb-out").innerHTML = benchOut()
|
|
2997
|
+
}
|
|
2998
|
+
$("#tb-save").onclick = async () => {
|
|
2999
|
+
bench.source = $("#tb-src").value
|
|
3000
|
+
try {
|
|
3001
|
+
await api(`/api/workspaces/${encodeURIComponent(ui.ws)}/tools/${encodeURIComponent(ui.id)}/source`, { method: "PUT", body: { text: bench.source } })
|
|
3002
|
+
$("#tb-err").innerHTML = `<span style="color:var(--ok)">Saved and reloaded.</span>`
|
|
3003
|
+
} catch (e) { $("#tb-err").textContent = e.message }
|
|
3004
|
+
}
|
|
3005
|
+
$("#tb-src").onkeydown = (e) => { if ((e.metaKey || e.ctrlKey) && e.key === "s") { e.preventDefault(); $("#tb-save").click() } }
|
|
3006
|
+
$("#tb-del").onclick = async () => {
|
|
3007
|
+
if (!confirm(`Delete botfarm/mcps/${ui.id}?`)) return
|
|
3008
|
+
await api(`/api/workspaces/${encodeURIComponent(ui.ws)}/tools/${encodeURIComponent(ui.id)}`, { method: "DELETE" }).catch((e) => alert(e.message))
|
|
3009
|
+
go("home")
|
|
3010
|
+
}
|
|
3011
|
+
}
|
|
3012
|
+
function benchOut() {
|
|
3013
|
+
const o = bench?.out
|
|
3014
|
+
if (!o) return ""
|
|
3015
|
+
const body = o.ok ? (typeof o.result === "string" ? o.result : JSON.stringify(o.result, null, 2)) : `${o.error}${o.stack ? "\n\n" + o.stack : ""}`
|
|
3016
|
+
return `<div class="benchout ${o.ok ? "ok" : "bad"}"><div class="bh"><b>${o.ok ? "Result" : "Error"}</b><span class="note mono">${fmt.ms(o.ms)}${o.directory ? " · in " + esc(fmt.path(o.directory)) : ""}</span></div>
|
|
3017
|
+
<pre>${esc(body)}</pre>${o.logs?.length ? `<div class="bh"><b>Log</b></div><pre class="log">${esc(o.logs.join("\n"))}</pre>` : ""}</div>`
|
|
3018
|
+
}
|
|
3019
|
+
|
|
3020
|
+
// ------------------------------------------------------------------ model routing
|
|
3021
|
+
// Three rows — easy, normal, hard — each a model and a reasoning level. Used by
|
|
3022
|
+
// the Routing page (workspace-wide) and the agent editor (one agent's own).
|
|
3023
|
+
const TIER_HELP = {
|
|
3024
|
+
easy: "Typos, docs, renames, small config changes, short single-file work.",
|
|
3025
|
+
normal: "Everyday changes. Leave empty to keep each agent's own model.",
|
|
3026
|
+
hard: "Refactors, migrations, security, concurrency, long stories with many criteria.",
|
|
3027
|
+
}
|
|
3028
|
+
function tierRows(prefix, tiers, models, usage = {}) {
|
|
3029
|
+
const list = `<datalist id="${prefix}-models">${models.map((m) => `<option value="${esc(m.id)}">${esc(m.provider ?? "")} · ${esc(m.name ?? m.id)}${m.variants?.length ? " · " + esc(m.variants.join("/")) : ""}</option>`).join("")}</datalist>`
|
|
3030
|
+
return list + `<div class="tiers">${["easy", "normal", "hard"].map((lv) => {
|
|
3031
|
+
const t = tiers?.[lv] ?? {}
|
|
3032
|
+
return `<div class="tier t-${lv}">
|
|
3033
|
+
<span class="diff d-${lv}">${lv}</span>
|
|
3034
|
+
<div class="field"><label for="${prefix}-${lv}-model">Model</label><input id="${prefix}-${lv}-model" class="mono" list="${prefix}-models" value="${esc(t.model ?? "")}" placeholder="the agent's own" data-tier="${lv}" /></div>
|
|
3035
|
+
<div class="field" style="max-width:150px"><label for="${prefix}-${lv}-variant">Reasoning</label><select id="${prefix}-${lv}-variant" data-tierv="${lv}" data-current="${esc(t.variant ?? "")}"></select></div>
|
|
3036
|
+
<span class="note tierhelp">${esc(TIER_HELP[lv])}</span>
|
|
3037
|
+
<details class="quota" ${t.quota ? "open" : ""}><summary>Quota and fallback ${t.quota ? `<span class="qsum">${esc(quotaText(t.quota))} → ${esc(t.fallback?.model ? shortModel(t.fallback.model) : "the agent's own")}${t.fallback?.variant ? " · " + esc(t.fallback.variant) : ""}</span>` : `<span class="note">— cap this model, then fall back to another</span>`}${usage[lv] ? quotaMeter(t.quota, usage[lv]) : ""}</summary>
|
|
3038
|
+
<div class="qgrid">
|
|
3039
|
+
<div class="field"><label>Up to $</label><input id="${prefix}-${lv}-q-usd" type="number" min="0" step="0.5" placeholder="—" value="${esc(t.quota?.usd ?? "")}" /></div>
|
|
3040
|
+
<div class="field"><label>or tokens</label><input id="${prefix}-${lv}-q-tokens" type="number" min="0" step="50000" placeholder="—" value="${esc(t.quota?.tokens ?? "")}" /></div>
|
|
3041
|
+
<div class="field"><label>or minutes</label><input id="${prefix}-${lv}-q-minutes" type="number" min="0" step="10" placeholder="—" value="${esc(t.quota?.minutes ?? "")}" /></div>
|
|
3042
|
+
<div class="field"><label>per</label><select id="${prefix}-${lv}-q-per">${["day", "week", "month", "workstream"].map((p) => `<option value="${p}" ${(t.quota?.per ?? "day") === p ? "selected" : ""}>${p}</option>`).join("")}</select></div>
|
|
3043
|
+
<div class="field" style="grid-column: span 2"><label>then use</label><input id="${prefix}-${lv}-fb-model" class="mono" list="${prefix}-models" value="${esc(t.fallback?.model ?? "")}" placeholder="the agent's own model" data-tier="${lv}-fb" /></div>
|
|
3044
|
+
<div class="field"><label>reasoning</label><select id="${prefix}-${lv}-fb-variant" data-current="${esc(t.fallback?.variant ?? "")}"></select></div>
|
|
3045
|
+
</div>
|
|
3046
|
+
<div class="note">Counted per workspace for this model. A bot already running on it moves to the fallback when the quota runs out.</div>
|
|
3047
|
+
</details>
|
|
3048
|
+
</div>`
|
|
3049
|
+
}).join("")}</div>`
|
|
3050
|
+
}
|
|
3051
|
+
function quotaText(q) {
|
|
3052
|
+
const parts = [q.usd && `$${q.usd}`, q.tokens && `${fmt.n(q.tokens)} tok`, q.minutes && `${q.minutes} min`].filter(Boolean)
|
|
3053
|
+
return `${parts.join(" / ")} per ${q.per ?? "day"}`
|
|
3054
|
+
}
|
|
3055
|
+
function quotaMeter(q, u) {
|
|
3056
|
+
if (!q) return ""
|
|
3057
|
+
const kind = ["usd", "tokens", "minutes"].find((k) => q[k])
|
|
3058
|
+
if (!kind) return ""
|
|
3059
|
+
const frac = Math.min(1, (u.spent?.[kind] ?? 0) / q[kind])
|
|
3060
|
+
const used = kind === "usd" ? fmt.money(u.spent.usd) : kind === "tokens" ? fmt.n(u.spent.tokens) : `${Math.round(u.spent.minutes)}m`
|
|
3061
|
+
return ` <span class="qmeter ${u.over ? "over" : ""}" title="${u.over ? "used up — cards go to the fallback" : "used so far this " + esc(u.per)}"><span class="bar"><i style="width:${Math.round(frac * 100)}%"></i></span>${used}${u.over ? " · using fallback" : ""}</span>`
|
|
3062
|
+
}
|
|
3063
|
+
function wireTierRows(prefix, models) {
|
|
3064
|
+
for (const lv of ["easy", "normal", "hard", "easy-fb", "normal-fb", "hard-fb"]) {
|
|
3065
|
+
const input = $(`#${prefix}-${lv}-model`)
|
|
3066
|
+
const sel = $(`#${prefix}-${lv}-variant`)
|
|
3067
|
+
if (!input || !sel) continue
|
|
3068
|
+
const paint = () => {
|
|
3069
|
+
const m = models.find((x) => x.id === input.value.trim())
|
|
3070
|
+
const opts = m?.variants?.length ? m.variants : ["low", "medium", "high"]
|
|
3071
|
+
const cur = sel.value || sel.dataset.current || ""
|
|
3072
|
+
sel.innerHTML = [`<option value="">default</option>`, ...opts.map((v) => `<option value="${esc(v)}" ${v === cur ? "selected" : ""}>${esc(v)}</option>`)].join("")
|
|
3073
|
+
}
|
|
3074
|
+
input.oninput = paint
|
|
3075
|
+
paint()
|
|
3076
|
+
}
|
|
3077
|
+
}
|
|
3078
|
+
function readTierRows(prefix) {
|
|
3079
|
+
const out = {}
|
|
3080
|
+
for (const lv of ["easy", "normal", "hard"]) {
|
|
3081
|
+
const t = {}
|
|
3082
|
+
const m = $(`#${prefix}-${lv}-model`)?.value.trim()
|
|
3083
|
+
const v = $(`#${prefix}-${lv}-variant`)?.value
|
|
3084
|
+
if (m) t.model = m
|
|
3085
|
+
if (v) t.variant = v
|
|
3086
|
+
const q = {}
|
|
3087
|
+
for (const k of ["usd", "tokens", "minutes"]) { const n = Number($(`#${prefix}-${lv}-q-${k}`)?.value); if (n > 0) q[k] = n }
|
|
3088
|
+
if (m && Object.keys(q).length) {
|
|
3089
|
+
q.per = $(`#${prefix}-${lv}-q-per`)?.value ?? "day"
|
|
3090
|
+
t.quota = q
|
|
3091
|
+
const fm = $(`#${prefix}-${lv}-fb-model`)?.value.trim()
|
|
3092
|
+
const fv = $(`#${prefix}-${lv}-fb-variant`)?.value
|
|
3093
|
+
t.fallback = { ...(fm ? { model: fm } : {}), ...(fv ? { variant: fv } : {}) }
|
|
3094
|
+
}
|
|
3095
|
+
out[lv] = t
|
|
3096
|
+
}
|
|
3097
|
+
return out
|
|
3098
|
+
}
|
|
3099
|
+
function agentMatrix(agents) {
|
|
3100
|
+
if (!agents.length) return ""
|
|
3101
|
+
const short = (m) => (m ? esc(String(m).split("/").pop()) : `<span class="note">agent's model</span>`)
|
|
3102
|
+
const cell = (c) => {
|
|
3103
|
+
const tag = c.source === "agent" ? `<span class="chip own">own rule</span>` : c.source === "workspace" ? `<span class="chip">workspace</span>` : `<span class="chip dim">no routing</span>`
|
|
3104
|
+
const q = c.quota ? `<div class="note">${c.quota.usd ? `$${(c.quota.spent?.usd ?? 0).toFixed(2)} / $${c.quota.usd}` : c.quota.tokens ? `${Math.round((c.quota.spent?.tokens ?? 0) / 1000)}k / ${Math.round(c.quota.tokens / 1000)}k tok` : `${Math.round(c.quota.spent?.minutes ?? 0)} / ${c.quota.minutes} min`} per ${esc(c.quota.per)} · ${esc(c.quota.scope)}${c.quota.over ? ` · <b style="color:var(--busy)">used up → ${short(c.fallback?.model)}</b>` : ""}</div>` : ""
|
|
3105
|
+
return `<td class="${c.source === "agent" ? "own" : ""}"><div class="mono">${short(c.model)}${c.variant ? ` · ${esc(c.variant)}` : ""}</div>${tag}${q}</td>`
|
|
3106
|
+
}
|
|
3107
|
+
return `<section class="panel" style="margin-top:12px"><h3>What each agent gets</h3>
|
|
3108
|
+
<p class="note" style="margin:0 0 8px">Per size, an agent's own rule wins; sizes it leaves out use the workspace rules above. An agent's own quota counts only that agent's spend. Click an agent to give it its own rules.</p>
|
|
3109
|
+
<table class="matrix"><thead><tr><th>Agent</th><th>easy</th><th>normal</th><th>hard</th></tr></thead><tbody>
|
|
3110
|
+
${agents.map((a) => `<tr><th><a href="#" data-agent="${esc(a.id)}">${esc(a.title)}</a><div class="note mono">@${esc(a.id)}</div></th>${["easy", "normal", "hard"].map((l) => cell(a.levels[l] ?? {})).join("")}</tr>`).join("")}
|
|
3111
|
+
</tbody></table></section>`
|
|
3112
|
+
}
|
|
3113
|
+
async function renderRouting(key) {
|
|
3114
|
+
mainKey = key
|
|
3115
|
+
$("#main").innerHTML = `<div class="page"><div class="note">Loading…</div></div>`
|
|
3116
|
+
const [doc, mods, lib] = await Promise.all([
|
|
3117
|
+
api(`/api/workspaces/${encodeURIComponent(ui.ws)}/routing`).catch(() => ({ enabled: false, tiers: {} })),
|
|
3118
|
+
api(`/api/workspaces/${encodeURIComponent(ui.ws)}/models`).catch(() => ({ models: [] })),
|
|
3119
|
+
library(ui.ws, true).catch(() => null),
|
|
3120
|
+
])
|
|
3121
|
+
const own = (lib?.agents ?? []).filter((a) => a.tiers)
|
|
3122
|
+
const pinned = (lib?.pipelines ?? []).flatMap((p) => p.stages.filter((s) => s.difficulty).map((s) => `${p.title ?? p.id} → ${s.id}: ${s.difficulty}`))
|
|
3123
|
+
$("#main").innerHTML = `<div class="page" style="max-width:980px">
|
|
3124
|
+
<div class="edhead"><h1>Model routing</h1><div class="acts"><button class="primary sm" id="rt-save">Save</button></div></div>
|
|
3125
|
+
<div class="note mono">botfarm/routing.botfarm.yml</div>
|
|
3126
|
+
<p class="lead" style="margin-top:10px">Each card is sized <b>easy</b>, <b>normal</b> or <b>hard</b> from its words, length and number of criteria — no model decides — and runs on the model you pick for that size. Cheap models for small cards, the big one only where it pays.</p>
|
|
3127
|
+
<label class="switch"><input type="checkbox" id="rt-on" ${doc.enabled ? "checked" : ""} /> <b>Route cards by difficulty</b> <span class="note">off: every bot uses its agent's model</span></label>
|
|
3128
|
+
<div class="field" style="max-width:520px;margin-top:8px"><label for="rt-switch">When a quota runs out mid-card</label><select id="rt-switch">
|
|
3129
|
+
<option value="now" ${doc.switch !== "next-turn" ? "selected" : ""}>switch at once — stop the turn and carry on on the fallback</option>
|
|
3130
|
+
<option value="next-turn" ${doc.switch === "next-turn" ? "selected" : ""}>switch on its next turn — let the current turn finish</option></select></div>
|
|
3131
|
+
<div class="err" id="rt-err"></div>
|
|
3132
|
+
<section class="panel" style="margin-top:12px">${tierRows("rt", doc.tiers ?? {}, mods.models ?? [], doc.usage ?? {})}
|
|
3133
|
+
<p class="note" style="margin:10px 0 0">Empty model: the agent's own model, only the reasoning level changes. Workspace default: <span class="mono">${esc(mods.default ?? "opencode's")}</span>.</p></section>
|
|
3134
|
+
${agentMatrix(doc.agents ?? [])}
|
|
3135
|
+
<section class="panel" style="margin-top:12px"><h3>Overrides</h3>
|
|
3136
|
+
<ul class="plain">
|
|
3137
|
+
<li>A bot's own <b>Model…</b> choice (in its panel) always wins.</li>
|
|
3138
|
+
<li>A stage can pin its size — <b>Difficulty</b> in the pipeline editor.${pinned.length ? `<br><span class="note mono">${pinned.map(esc).join("<br>")}</span>` : ""}</li>
|
|
3139
|
+
<li>A split piece can say its own size (the product bot is asked to mark obvious ones).</li>
|
|
3140
|
+
<li>An agent can route its own way — <b>Model by difficulty</b> in the agent editor.${own.length ? ` <span class="note">Set on: ${own.map((a) => `<a href="#" data-agent="${esc(a.id)}">${esc(a.title ?? a.id)}</a>`).join(", ")}</span>` : ""}</li>
|
|
3141
|
+
</ul></section>
|
|
3142
|
+
</div>`
|
|
3143
|
+
wireTierRows("rt", mods.models ?? [])
|
|
3144
|
+
for (const a of $$("[data-agent]")) a.onclick = (e) => { e.preventDefault(); go("agent", a.dataset.agent) }
|
|
3145
|
+
$("#rt-save").onclick = async () => {
|
|
3146
|
+
try {
|
|
3147
|
+
await api(`/api/workspaces/${encodeURIComponent(ui.ws)}/routing`, { method: "PUT", body: { enabled: $("#rt-on").checked, switch: $("#rt-switch").value, tiers: readTierRows("rt") } })
|
|
3148
|
+
$("#rt-err").innerHTML = `<span style="color:var(--ok)">Saved. New cards are routed from now on; cards already running keep their model.</span>`
|
|
3149
|
+
} catch (e) { $("#rt-err").textContent = e.message }
|
|
3150
|
+
}
|
|
3151
|
+
}
|
|
3152
|
+
|
|
3153
|
+
// ------------------------------------------------------------------ agent editor
|
|
3154
|
+
async function renderAgent(key) {
|
|
3155
|
+
mainKey = key
|
|
3156
|
+
const lib = await library(ui.ws, true).catch(() => null)
|
|
3157
|
+
if (!lib) return
|
|
3158
|
+
const isNew = ui.id === "+new"
|
|
3159
|
+
const found = lib.agents.find((a) => a.id === ui.id)
|
|
3160
|
+
if (!isNew && !found) { $("#main").innerHTML = `<div class="page"><div class="empty">No agent "${esc(ui.id)}" in this workspace.</div></div>`; return }
|
|
3161
|
+
lib.models = (await api(`/api/workspaces/${encodeURIComponent(ui.ws)}/models`).catch(() => ({ models: [] }))).models
|
|
3162
|
+
ed = { kind: "agents", id: isNew ? null : found.id, doc: isNew ? { id: "", title: "", role: "", prompt: "" } : JSON.parse(JSON.stringify(found)), isNew, tab: "form", lib }
|
|
3163
|
+
paintAgent()
|
|
3164
|
+
}
|
|
3165
|
+
function paintAgent() {
|
|
3166
|
+
const { doc, isNew, lib } = ed
|
|
3167
|
+
const usedBy = (lib.pipelines ?? []).filter((p) => p.stages.some((s) => s.persona === doc.id)).map((p) => p.title ?? p.id)
|
|
3168
|
+
$("#main").innerHTML = `<div class="page">
|
|
3169
|
+
<div class="edhead"><h1>${isNew ? "New agent" : "@" + esc(doc.id)}</h1>
|
|
3170
|
+
<div class="acts">${!isNew ? `<button class="sm danger" id="ae-del">Delete</button>` : ""}<button class="primary sm" id="ae-save">Save</button></div></div>
|
|
3171
|
+
<div class="note mono">${esc(isNew ? "botfarm/<id>.agent.botfarm.yml" : doc.file ?? "")}${usedBy.length ? ` · used by ${esc(usedBy.join(", "))}` : ""}</div>
|
|
3172
|
+
<div class="tabs"><button class="sm ghost" data-tab="form" aria-pressed="${ed.tab === "form"}">Agent</button><button class="sm ghost" data-tab="yaml" aria-pressed="${ed.tab === "yaml"}">This file (YAML)</button></div>
|
|
3173
|
+
<div class="err" id="ae-err"></div>
|
|
3174
|
+
<div id="ae-body"></div></div>`
|
|
3175
|
+
for (const b of $$("[data-tab]")) b.onclick = () => { if (ed.tab === "form") readAgentForm(); ed.tab = b.dataset.tab; paintAgent() }
|
|
3176
|
+
$("#ae-save").onclick = saveAgent
|
|
3177
|
+
if ($("#ae-del")) $("#ae-del").onclick = async () => {
|
|
3178
|
+
if (!confirm(`Delete agent "${ed.id}"? This deletes botfarm/${ed.id}.agent.botfarm.yml.`)) return
|
|
3179
|
+
await api(`/api/workspaces/${encodeURIComponent(ui.ws)}/agents/${encodeURIComponent(ed.id)}`, { method: "DELETE" })
|
|
3180
|
+
libs.delete(ui.ws)
|
|
3181
|
+
go("home")
|
|
3182
|
+
}
|
|
3183
|
+
if (ed.tab === "yaml") return paintRawInto("#ae-body", "agents")
|
|
3184
|
+
$("#ae-body").innerHTML = `<div style="max-width:900px">
|
|
3185
|
+
<div class="row">
|
|
3186
|
+
<div class="field"><label>Name</label><input id="ae-title" value="${esc(doc.title ?? "")}" placeholder="QA Bot" /></div>
|
|
3187
|
+
<div class="field" style="max-width:200px"><label>Id (used in pipelines)</label><input id="ae-id" class="mono" value="${esc(doc.id ?? "")}" ${isNew ? "" : "disabled"} placeholder="qa" /></div>
|
|
3188
|
+
<div class="field" style="max-width:200px"><label>Role</label><input id="ae-role" class="mono" value="${esc(doc.role ?? "")}" placeholder="qa" /><span class="hint">Cards for a role go to whoever plays it.</span></div>
|
|
3189
|
+
</div>
|
|
3190
|
+
<div class="row">
|
|
3191
|
+
<div class="field"><label>opencode agent</label><input id="ae-agent" class="mono" list="ae-agents" value="${esc(doc.agent ?? "")}" placeholder="${lib.opencode?.agents?.some((a) => a.name === "build") ? "build" : "opencode default"}" />
|
|
3192
|
+
<datalist id="ae-agents">${(lib.opencode?.agents ?? []).map((a) => `<option value="${esc(a.name)}">${esc([a.model, a.variant, a.description].filter(Boolean).join(" · "))}</option>`).join("")}</datalist>
|
|
3193
|
+
<span class="hint">From your opencode.json. Its model and reasoning level apply unless set here.</span></div>
|
|
3194
|
+
<div class="field"><label>Model</label><input id="ae-model" class="mono" list="ae-models" value="${esc(doc.model ?? "")}" placeholder="${esc(resolvedModel(lib, doc) ?? "opencode default")}" />
|
|
3195
|
+
<datalist id="ae-models">${[...new Set([lib.opencode?.model, ...(lib.opencode?.agents ?? []).map((a) => a.model)].filter(Boolean))].map((m) => `<option value="${esc(m)}"></option>`).join("")}</datalist></div>
|
|
3196
|
+
<div class="field" style="max-width:150px"><label>Reasoning</label><input id="ae-variant" class="mono" list="ae-variants" value="${esc(doc.variant ?? "")}" placeholder="${esc(resolvedVariant(lib, doc) ?? "default")}" />
|
|
3197
|
+
<datalist id="ae-variants"><option value="low"></option><option value="medium"></option><option value="high"></option></datalist></div>
|
|
3198
|
+
</div>
|
|
3199
|
+
<div class="row">
|
|
3200
|
+
<div class="field"><label>Tools</label><input id="ae-tools" class="mono" value="${esc((doc.tools ?? []).join(", "))}" placeholder="all tools (or e.g. read, grep, glob)" /></div>
|
|
3201
|
+
<div class="field" style="max-width:180px"><label>May open sessions</label><select id="ae-spawn"><option value="">no</option><option value="1" ${doc.may_spawn ? "selected" : ""}>yes</option></select></div>
|
|
3202
|
+
</div>
|
|
3203
|
+
<details class="tierbox" ${doc.tiers ? "open" : ""}><summary><b>Model by difficulty</b> <span class="note">${doc.tiers ? "this agent routes its own way" : "uses the workspace routing"} — optional</span></summary>
|
|
3204
|
+
<p class="note" style="margin:6px 0 10px">Leave empty to follow <a href="#" data-go-routing>the workspace routing</a>. Fill a row to give this agent its own model or reasoning level for cards of that size.</p>
|
|
3205
|
+
${tierRows("ae-t", doc.tiers ?? {}, lib.models ?? [])}
|
|
3206
|
+
</details>
|
|
3207
|
+
<div class="field"><label>Instructions</label><textarea id="ae-prompt" class="code" rows="18">${esc(doc.prompt ?? "")}</textarea>
|
|
3208
|
+
<span class="hint">Sent once, together with the agent's first card in a workstream.</span></div></div>`
|
|
3209
|
+
wireTierRows("ae-t", lib.models ?? [])
|
|
3210
|
+
$("[data-go-routing]")?.addEventListener("click", (e) => { e.preventDefault(); go("routing") })
|
|
3211
|
+
}
|
|
3212
|
+
async function paintRawInto(sel, kind) {
|
|
3213
|
+
if (!ed.id) { $(sel).innerHTML = `<div class="note">Save it from the form first — the file is named after its id.</div>`; return }
|
|
3214
|
+
const f = await api(`/api/workspaces/${encodeURIComponent(ui.ws)}/file/${kind}?entry=${encodeURIComponent(ed.id)}`)
|
|
3215
|
+
$(sel).innerHTML = `<textarea id="raw" class="code" spellcheck="false" style="width:100%;max-width:1000px;min-height:65vh">${esc(f.text)}</textarea>`
|
|
3216
|
+
}
|
|
3217
|
+
function ocAgent(lib, doc) {
|
|
3218
|
+
const name = doc.agent ?? (lib.opencode?.agents?.some((a) => a.name === "build") ? "build" : null)
|
|
3219
|
+
return (lib.opencode?.agents ?? []).find((a) => a.name === name) ?? null
|
|
3220
|
+
}
|
|
3221
|
+
const resolvedModel = (lib, doc) => doc.model ?? ocAgent(lib, doc)?.model ?? lib.opencode?.model ?? null
|
|
3222
|
+
const resolvedVariant = (lib, doc) => doc.variant ?? ocAgent(lib, doc)?.variant ?? null
|
|
3223
|
+
function readAgentForm() {
|
|
3224
|
+
if (!$("#ae-title")) return
|
|
3225
|
+
const d = ed.doc
|
|
3226
|
+
d.title = $("#ae-title").value.trim()
|
|
3227
|
+
if (ed.isNew) d.id = $("#ae-id").value.trim() || slug(d.title)
|
|
3228
|
+
d.role = $("#ae-role").value.trim() || d.id
|
|
3229
|
+
d.model = $("#ae-model").value.trim() || undefined
|
|
3230
|
+
d.agent = $("#ae-agent").value.trim() || undefined
|
|
3231
|
+
d.variant = $("#ae-variant").value.trim() || undefined
|
|
3232
|
+
const tools = $("#ae-tools").value.split(/[,\s]+/).filter(Boolean)
|
|
3233
|
+
d.tools = tools.length ? tools : undefined
|
|
3234
|
+
d.may_spawn = $("#ae-spawn").value ? true : undefined
|
|
3235
|
+
d.prompt = $("#ae-prompt").value
|
|
3236
|
+
const tiers = readTierRows("ae-t")
|
|
3237
|
+
d.tiers = Object.values(tiers).some((t) => Object.keys(t).length) ? tiers : undefined
|
|
3238
|
+
}
|
|
3239
|
+
async function saveAgent() {
|
|
3240
|
+
if (ed.tab === "yaml") return saveRaw("agents")
|
|
3241
|
+
readAgentForm()
|
|
3242
|
+
const d = ed.doc
|
|
3243
|
+
if (!d.id) return ($("#ae-err").textContent = "Give it a name or an id.")
|
|
3244
|
+
const clean = Object.fromEntries(Object.entries(d).filter(([, v]) => v !== undefined && v !== ""))
|
|
3245
|
+
try {
|
|
3246
|
+
const lib = await api(`/api/workspaces/${encodeURIComponent(ui.ws)}/agents/${encodeURIComponent(d.id)}`, { method: "PUT", body: { doc: clean } })
|
|
3247
|
+
libs.set(ui.ws, { ...libs.get(ui.ws), ...lib })
|
|
3248
|
+
navSig = null
|
|
3249
|
+
if (ed.isNew) return go("agent", d.id)
|
|
3250
|
+
ed.lib = libs.get(ui.ws)
|
|
3251
|
+
paintAgent()
|
|
3252
|
+
$("#ae-err").innerHTML = `<span style="color:var(--ok)">Saved to ${esc(ed.doc.file ?? "botfarm/" + ed.doc.id + ".agent.botfarm.yml")}.</span>`
|
|
3253
|
+
} catch (e) { $("#ae-err").textContent = e.message }
|
|
3254
|
+
}
|
|
3255
|
+
|
|
3256
|
+
// ------------------------------------------------------------------ socket
|
|
3257
|
+
let sock = null
|
|
3258
|
+
const wsSend = (text) => { if (sock?.readyState === 1) sock.send(text) }
|
|
3259
|
+
function connect() {
|
|
3260
|
+
const open = () => {
|
|
3261
|
+
const ws = new WebSocket(`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/api/socket`)
|
|
3262
|
+
ws.onopen = () => {
|
|
3263
|
+
sock = ws
|
|
3264
|
+
ws.send("resync")
|
|
3265
|
+
if (drawer?.kind === "session") ws.send(`watch:${drawer.id}`)
|
|
3266
|
+
}
|
|
3267
|
+
ws.onmessage = (e) => {
|
|
3268
|
+
const msg = JSON.parse(e.data)
|
|
3269
|
+
if (msg.t) return onLive(msg)
|
|
3270
|
+
const first = !state.at
|
|
3271
|
+
state = msg
|
|
3272
|
+
if (first) route()
|
|
3273
|
+
else render()
|
|
3274
|
+
renderKv()
|
|
3275
|
+
}
|
|
3276
|
+
ws.onclose = () => { sock = null; $("#conn").textContent = "reconnecting…"; setTimeout(open, 1500) }
|
|
3277
|
+
}
|
|
3278
|
+
open()
|
|
3279
|
+
}
|
|
3280
|
+
connect()
|
|
3281
|
+
setInterval(() => { if (state.at && ui.kind === "stream") render() }, 15000) // keep "working 3m" honest
|
|
3282
|
+
</script>
|
|
3283
|
+
</body>
|
|
3284
|
+
</html>
|