codeaway 0.1.0__py3-none-any.whl
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.
- codeaway/__init__.py +4 -0
- codeaway/__main__.py +5 -0
- codeaway/agents.py +427 -0
- codeaway/cli.py +221 -0
- codeaway/config.py +157 -0
- codeaway/desktop.py +644 -0
- codeaway/server.py +673 -0
- codeaway/web/__init__.py +1 -0
- codeaway/web/app.js +747 -0
- codeaway/web/index.html +41 -0
- codeaway/web/setup.html +57 -0
- codeaway/web/style.css +280 -0
- codeaway-0.1.0.dist-info/METADATA +113 -0
- codeaway-0.1.0.dist-info/RECORD +17 -0
- codeaway-0.1.0.dist-info/WHEEL +4 -0
- codeaway-0.1.0.dist-info/entry_points.txt +2 -0
- codeaway-0.1.0.dist-info/licenses/LICENSE +21 -0
codeaway/web/app.js
ADDED
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const setupDiagramLabels = ["Sidebar", "Conversation", "Composer"];
|
|
4
|
+
const regionNames = ["sidebar", "conversation", "composer"];
|
|
5
|
+
const minimumRegionSize = 0.01;
|
|
6
|
+
|
|
7
|
+
function clamp(value, minimum, maximum) {
|
|
8
|
+
return Math.min(maximum, Math.max(minimum, value));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function pointToFraction(clientX, clientY, box) {
|
|
12
|
+
return {
|
|
13
|
+
x: clamp((clientX - box.left) / box.width, 0, 1),
|
|
14
|
+
y: clamp((clientY - box.top) / box.height, 0, 1),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function swipeToSteps(deltaY) {
|
|
19
|
+
if (Math.abs(deltaY) <= 8) return 0;
|
|
20
|
+
const steps = clamp(Math.round(deltaY / 24), -12, 12);
|
|
21
|
+
return steps || Math.sign(deltaY);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function toggleProject(expanded, project) {
|
|
25
|
+
return { ...expanded, [project]: !expanded[project] };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeRectangle(start, end, width, height) {
|
|
29
|
+
const startX = clamp(start.x / width, 0, 1);
|
|
30
|
+
const startY = clamp(start.y / height, 0, 1);
|
|
31
|
+
const endX = clamp(end.x / width, 0, 1);
|
|
32
|
+
const endY = clamp(end.y / height, 0, 1);
|
|
33
|
+
return {
|
|
34
|
+
x: Math.min(startX, endX),
|
|
35
|
+
y: Math.min(startY, endY),
|
|
36
|
+
width: Math.abs(endX - startX),
|
|
37
|
+
height: Math.abs(endY - startY),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function surfaceObject([x, y, width, height]) {
|
|
42
|
+
return { x, y, width, height };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function surfacesFromApi(surfaces) {
|
|
46
|
+
return Object.fromEntries(
|
|
47
|
+
regionNames.map((name) => [name, surfaceObject(surfaces[name])]),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function surfacesForApi(surfaces) {
|
|
52
|
+
return Object.fromEntries(
|
|
53
|
+
regionNames.map((name) => {
|
|
54
|
+
const { x, y, width, height } = surfaces[name];
|
|
55
|
+
return [name, [x, y, width, height]];
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function jsonRequest(method, value) {
|
|
61
|
+
return {
|
|
62
|
+
method,
|
|
63
|
+
headers: { "Content-Type": "application/json" },
|
|
64
|
+
body: JSON.stringify(value),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function calibrationRequest(surfaces) {
|
|
69
|
+
return jsonRequest("PUT", { surfaces: surfacesForApi(surfaces) });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function phoneUrlForStatus({ bind_ip, port }) {
|
|
73
|
+
if (bind_ip.includes(":")) {
|
|
74
|
+
throw new Error("CodeAway v0.1 requires an IPv4 address.");
|
|
75
|
+
}
|
|
76
|
+
return `http://${bind_ip}:${port}/`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function createSetupModel(surfaces, status) {
|
|
80
|
+
const model = {
|
|
81
|
+
phoneUrl: phoneUrlForStatus(status),
|
|
82
|
+
surfaces: surfacesFromApi(surfaces),
|
|
83
|
+
replace(name, rectangle) {
|
|
84
|
+
model.surfaces[name] = { ...rectangle };
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
return model;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function createPhoneController({ postAction, requestImage, onComposerClear = () => {} }) {
|
|
91
|
+
const state = {
|
|
92
|
+
composerText: "",
|
|
93
|
+
conversationError: "",
|
|
94
|
+
imageRevision: null,
|
|
95
|
+
imageRequestToken: 0,
|
|
96
|
+
requestedImageRevision: null,
|
|
97
|
+
};
|
|
98
|
+
const controller = {
|
|
99
|
+
get composerText() {
|
|
100
|
+
return state.composerText;
|
|
101
|
+
},
|
|
102
|
+
get conversationError() {
|
|
103
|
+
return state.conversationError;
|
|
104
|
+
},
|
|
105
|
+
get imageRevision() {
|
|
106
|
+
return state.imageRevision;
|
|
107
|
+
},
|
|
108
|
+
setComposerText(text) {
|
|
109
|
+
state.composerText = text;
|
|
110
|
+
},
|
|
111
|
+
canHandleGesture(image) {
|
|
112
|
+
return state.imageRevision !== null && image.naturalWidth > 0;
|
|
113
|
+
},
|
|
114
|
+
async refreshConversation(revision, successfulAction = false) {
|
|
115
|
+
if (
|
|
116
|
+
state.requestedImageRevision !== null
|
|
117
|
+
&& (
|
|
118
|
+
revision < state.requestedImageRevision
|
|
119
|
+
|| (!successfulAction && revision === state.requestedImageRevision)
|
|
120
|
+
)
|
|
121
|
+
) return false;
|
|
122
|
+
state.requestedImageRevision = revision;
|
|
123
|
+
state.imageRevision = null;
|
|
124
|
+
const requestToken = ++state.imageRequestToken;
|
|
125
|
+
try {
|
|
126
|
+
await requestImage(revision);
|
|
127
|
+
if (requestToken !== state.imageRequestToken) return false;
|
|
128
|
+
state.imageRevision = revision;
|
|
129
|
+
state.conversationError = "";
|
|
130
|
+
return true;
|
|
131
|
+
} catch (_) {
|
|
132
|
+
if (requestToken !== state.imageRequestToken) return false;
|
|
133
|
+
state.conversationError = "The conversation image could not be refreshed.";
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
async performAction(value) {
|
|
138
|
+
const result = await postAction(value);
|
|
139
|
+
await controller.refreshConversation(result.revision, true);
|
|
140
|
+
return result;
|
|
141
|
+
},
|
|
142
|
+
async send() {
|
|
143
|
+
const result = await postAction({ kind: "send", text: state.composerText });
|
|
144
|
+
state.composerText = "";
|
|
145
|
+
onComposerClear();
|
|
146
|
+
await controller.refreshConversation(result.revision, true);
|
|
147
|
+
return result;
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
return controller;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function initializePhoneWorkspace({
|
|
154
|
+
documentRef = document,
|
|
155
|
+
windowRef = window,
|
|
156
|
+
fetchFn = fetch,
|
|
157
|
+
} = {}) {
|
|
158
|
+
const elements = {
|
|
159
|
+
composer: documentRef.querySelector("#composer"),
|
|
160
|
+
composerInput: documentRef.querySelector("#composer-input"),
|
|
161
|
+
composerMessage: documentRef.querySelector("#composer-message"),
|
|
162
|
+
composerSend: documentRef.querySelector("#composer-send"),
|
|
163
|
+
conversationImage: documentRef.querySelector("#conversation-image"),
|
|
164
|
+
conversationMessage: documentRef.querySelector("#conversation-message"),
|
|
165
|
+
navigatorProjects: documentRef.querySelector("#navigator-projects"),
|
|
166
|
+
statusMessage: documentRef.querySelector("#status-message"),
|
|
167
|
+
};
|
|
168
|
+
const state = {
|
|
169
|
+
actionBusy: false,
|
|
170
|
+
expanded: {},
|
|
171
|
+
gesture: null,
|
|
172
|
+
navigator: null,
|
|
173
|
+
pollTimer: null,
|
|
174
|
+
refreshing: null,
|
|
175
|
+
revision: null,
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
function showMessage(element, message, error = false) {
|
|
179
|
+
element.textContent = message;
|
|
180
|
+
element.classList.toggle("error", error);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function request(path, options = {}) {
|
|
184
|
+
const response = await fetchFn(path, options);
|
|
185
|
+
if (!response.ok) {
|
|
186
|
+
let message = `Request failed (${response.status}).`;
|
|
187
|
+
try { message = (await response.json()).error.message; } catch (_) { /* use status */ }
|
|
188
|
+
throw new Error(message);
|
|
189
|
+
}
|
|
190
|
+
return response.json();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function actionRequest(value) {
|
|
194
|
+
return {
|
|
195
|
+
method: "POST",
|
|
196
|
+
headers: { "Content-Type": "application/json" },
|
|
197
|
+
body: JSON.stringify(value),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function svgIcon(className, path, label = null) {
|
|
202
|
+
const svg = documentRef.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
203
|
+
svg.setAttribute("class", className);
|
|
204
|
+
svg.setAttribute("viewBox", "0 0 24 24");
|
|
205
|
+
svg.setAttribute("width", "16");
|
|
206
|
+
svg.setAttribute("height", "16");
|
|
207
|
+
if (label) {
|
|
208
|
+
svg.setAttribute("aria-label", label);
|
|
209
|
+
svg.setAttribute("role", "img");
|
|
210
|
+
svg.setAttribute("title", label);
|
|
211
|
+
} else {
|
|
212
|
+
svg.setAttribute("aria-hidden", "true");
|
|
213
|
+
}
|
|
214
|
+
const shape = documentRef.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
215
|
+
shape.setAttribute("d", path);
|
|
216
|
+
shape.setAttribute("fill", "none");
|
|
217
|
+
shape.setAttribute("stroke", "currentColor");
|
|
218
|
+
shape.setAttribute("stroke-width", "2");
|
|
219
|
+
shape.setAttribute("stroke-linecap", "round");
|
|
220
|
+
shape.setAttribute("stroke-linejoin", "round");
|
|
221
|
+
svg.append(shape);
|
|
222
|
+
return svg;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function projectStatusIcon(state) {
|
|
226
|
+
const [kind, label] = state === "busy"
|
|
227
|
+
? ["busy", "Busy"]
|
|
228
|
+
: ["connected", "Connected"];
|
|
229
|
+
const path = kind === "busy"
|
|
230
|
+
? "M12 3a9 9 0 1 0 9 9"
|
|
231
|
+
: "M12 5.5a6.5 6.5 0 1 0 0 13a6.5 6.5 0 1 0 0-13";
|
|
232
|
+
return svgIcon(`status-icon status-icon--${kind}`, path, label);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function taskStatusIcon(state) {
|
|
236
|
+
if (state !== "busy" && state !== "done") return null;
|
|
237
|
+
const [kind, label] = state === "busy" ? ["busy", "Busy"] : ["ready", "Ready"];
|
|
238
|
+
const path = kind === "busy"
|
|
239
|
+
? "M12 3a9 9 0 1 0 9 9"
|
|
240
|
+
: "M12 5.5a6.5 6.5 0 1 0 0 13a6.5 6.5 0 1 0 0-13";
|
|
241
|
+
return svgIcon(`status-icon status-icon--${kind}`, path, label);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function renderStatus(status) {
|
|
245
|
+
if (status.ready) {
|
|
246
|
+
const target = status.target?.title || "agent";
|
|
247
|
+
const agent = status.target?.agent_id || "agent";
|
|
248
|
+
showMessage(elements.statusMessage, `Connected: ${agent} — ${target} (ready).`);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
showMessage(elements.statusMessage, "Setup is required before controls are available.", true);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function renderNavigator(snapshot) {
|
|
255
|
+
elements.navigatorProjects.replaceChildren();
|
|
256
|
+
if (!snapshot.available) {
|
|
257
|
+
elements.navigatorProjects.textContent = snapshot.error || "The navigator is unavailable.";
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
for (const project of snapshot.projects) {
|
|
261
|
+
if (!(project.name in state.expanded)) state.expanded[project.name] = project.expanded;
|
|
262
|
+
const expanded = state.expanded[project.name];
|
|
263
|
+
const projectItem = documentRef.createElement("section");
|
|
264
|
+
projectItem.className = "project";
|
|
265
|
+
|
|
266
|
+
const toggle = documentRef.createElement("button");
|
|
267
|
+
toggle.className = "project-toggle";
|
|
268
|
+
toggle.type = "button";
|
|
269
|
+
toggle.setAttribute("aria-expanded", String(expanded));
|
|
270
|
+
const chevron = svgIcon("project-chevron", "m8 9 4 4 4-4");
|
|
271
|
+
chevron.classList.toggle("expanded", expanded);
|
|
272
|
+
const name = documentRef.createElement("span");
|
|
273
|
+
name.className = "project-name";
|
|
274
|
+
name.textContent = project.name;
|
|
275
|
+
const projectMeta = documentRef.createElement("span");
|
|
276
|
+
projectMeta.className = "project-meta";
|
|
277
|
+
const host = documentRef.createElement("span");
|
|
278
|
+
host.className = "project-host";
|
|
279
|
+
host.textContent = project.host || "local";
|
|
280
|
+
projectMeta.append(host, projectStatusIcon(project.state));
|
|
281
|
+
toggle.append(chevron, name, projectMeta);
|
|
282
|
+
toggle.addEventListener("click", () => {
|
|
283
|
+
state.expanded = toggleProject(state.expanded, project.name);
|
|
284
|
+
renderNavigator(state.navigator);
|
|
285
|
+
});
|
|
286
|
+
projectItem.append(toggle);
|
|
287
|
+
|
|
288
|
+
const tasks = documentRef.createElement("div");
|
|
289
|
+
tasks.className = "task-list";
|
|
290
|
+
tasks.hidden = !expanded;
|
|
291
|
+
for (const task of project.tasks) {
|
|
292
|
+
const taskButton = documentRef.createElement("button");
|
|
293
|
+
taskButton.className = "task";
|
|
294
|
+
taskButton.type = "button";
|
|
295
|
+
taskButton.classList.toggle("selected", task.selected);
|
|
296
|
+
const taskMeta = documentRef.createElement("span");
|
|
297
|
+
taskMeta.className = "task-meta";
|
|
298
|
+
if (task.worktree) {
|
|
299
|
+
taskMeta.append(svgIcon(
|
|
300
|
+
"worktree-marker",
|
|
301
|
+
"M12 4v6m0 0-6 6m6-6 6 6M12 4a2 2 0 1 0 0 .01M6 18a2 2 0 1 0 0 .01M18 18a2 2 0 1 0 0 .01",
|
|
302
|
+
"Worktree",
|
|
303
|
+
));
|
|
304
|
+
}
|
|
305
|
+
const title = documentRef.createElement("span");
|
|
306
|
+
title.textContent = task.title;
|
|
307
|
+
const statusIcon = taskStatusIcon(task.state);
|
|
308
|
+
if (statusIcon) taskMeta.append(statusIcon);
|
|
309
|
+
taskButton.append(title, taskMeta);
|
|
310
|
+
taskButton.addEventListener("click", async () => {
|
|
311
|
+
if (state.actionBusy) return;
|
|
312
|
+
try {
|
|
313
|
+
await performAction({ kind: "navigate", target: "task", project: project.name, title: task.title });
|
|
314
|
+
showMessage(elements.conversationMessage, "");
|
|
315
|
+
} catch (error) {
|
|
316
|
+
showMessage(elements.conversationMessage, error.message, true);
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
tasks.append(taskButton);
|
|
320
|
+
}
|
|
321
|
+
projectItem.append(tasks);
|
|
322
|
+
elements.navigatorProjects.append(projectItem);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function requestConversationImage(revision) {
|
|
327
|
+
return new Promise((resolve, reject) => {
|
|
328
|
+
const image = elements.conversationImage;
|
|
329
|
+
const cleanup = () => {
|
|
330
|
+
image.removeEventListener("load", loaded);
|
|
331
|
+
image.removeEventListener("error", failed);
|
|
332
|
+
};
|
|
333
|
+
const loaded = () => {
|
|
334
|
+
cleanup();
|
|
335
|
+
resolve();
|
|
336
|
+
};
|
|
337
|
+
const failed = () => {
|
|
338
|
+
cleanup();
|
|
339
|
+
reject(new Error("The conversation image could not be refreshed."));
|
|
340
|
+
};
|
|
341
|
+
image.addEventListener("load", loaded, { once: true });
|
|
342
|
+
image.addEventListener("error", failed, { once: true });
|
|
343
|
+
image.src = `/api/screenshot/conversation?revision=${encodeURIComponent(revision)}`;
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const phone = createPhoneController({
|
|
348
|
+
postAction: (value) => request("/api/action", actionRequest(value)),
|
|
349
|
+
requestImage: requestConversationImage,
|
|
350
|
+
onComposerClear: () => { elements.composerInput.value = ""; },
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
function showConversationRefreshStatus() {
|
|
354
|
+
showMessage(
|
|
355
|
+
elements.conversationMessage,
|
|
356
|
+
phone.conversationError,
|
|
357
|
+
Boolean(phone.conversationError),
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function performAction(value) {
|
|
362
|
+
if (state.actionBusy) return;
|
|
363
|
+
state.actionBusy = true;
|
|
364
|
+
try {
|
|
365
|
+
const result = await phone.performAction(value);
|
|
366
|
+
state.revision = result.revision;
|
|
367
|
+
showConversationRefreshStatus();
|
|
368
|
+
return result;
|
|
369
|
+
} finally {
|
|
370
|
+
state.actionBusy = false;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function refreshWorkspace() {
|
|
375
|
+
if (state.refreshing) return state.refreshing;
|
|
376
|
+
state.refreshing = (async () => {
|
|
377
|
+
const [statusResult, navigatorResult] = await Promise.allSettled([
|
|
378
|
+
request("/api/status"),
|
|
379
|
+
request("/api/navigator"),
|
|
380
|
+
]);
|
|
381
|
+
if (statusResult.status === "fulfilled") {
|
|
382
|
+
const status = statusResult.value;
|
|
383
|
+
if (state.revision === null || status.revision >= state.revision) {
|
|
384
|
+
state.revision = status.revision;
|
|
385
|
+
renderStatus(status);
|
|
386
|
+
await phone.refreshConversation(status.revision);
|
|
387
|
+
showConversationRefreshStatus();
|
|
388
|
+
}
|
|
389
|
+
} else {
|
|
390
|
+
showMessage(elements.statusMessage, statusResult.reason.message, true);
|
|
391
|
+
}
|
|
392
|
+
if (navigatorResult.status === "fulfilled") {
|
|
393
|
+
state.navigator = navigatorResult.value;
|
|
394
|
+
renderNavigator(state.navigator);
|
|
395
|
+
} else {
|
|
396
|
+
elements.navigatorProjects.textContent = navigatorResult.reason.message;
|
|
397
|
+
}
|
|
398
|
+
})();
|
|
399
|
+
try {
|
|
400
|
+
await state.refreshing;
|
|
401
|
+
} finally {
|
|
402
|
+
state.refreshing = null;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function startPolling() {
|
|
407
|
+
if (state.pollTimer !== null) return state.refreshing || Promise.resolve();
|
|
408
|
+
const refresh = refreshWorkspace();
|
|
409
|
+
state.pollTimer = windowRef.setInterval(refreshWorkspace, 2000);
|
|
410
|
+
return refresh;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function stopPolling() {
|
|
414
|
+
if (state.pollTimer === null) return;
|
|
415
|
+
windowRef.clearInterval(state.pollTimer);
|
|
416
|
+
state.pollTimer = null;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
elements.conversationImage.addEventListener("pointerdown", (event) => {
|
|
420
|
+
if (state.actionBusy || !phone.canHandleGesture(elements.conversationImage)) return;
|
|
421
|
+
event.preventDefault();
|
|
422
|
+
state.gesture = { id: event.pointerId, x: event.clientX, y: event.clientY };
|
|
423
|
+
elements.conversationImage.setPointerCapture(event.pointerId);
|
|
424
|
+
});
|
|
425
|
+
elements.conversationImage.addEventListener("pointerup", async (event) => {
|
|
426
|
+
const gesture = state.gesture;
|
|
427
|
+
if (!gesture || gesture.id !== event.pointerId || state.actionBusy) return;
|
|
428
|
+
state.gesture = null;
|
|
429
|
+
const deltaY = event.clientY - gesture.y;
|
|
430
|
+
const distance = Math.hypot(event.clientX - gesture.x, deltaY);
|
|
431
|
+
let action = null;
|
|
432
|
+
if (distance < 8) {
|
|
433
|
+
const point = pointToFraction(event.clientX, event.clientY, elements.conversationImage.getBoundingClientRect());
|
|
434
|
+
action = { kind: "click", surface: "conversation", ...point };
|
|
435
|
+
} else if (Math.abs(deltaY) >= 8) {
|
|
436
|
+
const amount = swipeToSteps(deltaY);
|
|
437
|
+
if (amount) action = { kind: "scroll", amount };
|
|
438
|
+
}
|
|
439
|
+
if (!action) return;
|
|
440
|
+
try {
|
|
441
|
+
await performAction(action);
|
|
442
|
+
showConversationRefreshStatus();
|
|
443
|
+
} catch (error) {
|
|
444
|
+
showMessage(elements.conversationMessage, error.message, true);
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
elements.conversationImage.addEventListener("pointercancel", () => {
|
|
448
|
+
state.gesture = null;
|
|
449
|
+
});
|
|
450
|
+
elements.composer.addEventListener("submit", async (event) => {
|
|
451
|
+
event.preventDefault();
|
|
452
|
+
const text = elements.composerInput.value;
|
|
453
|
+
if (!text.trim() || state.actionBusy) return;
|
|
454
|
+
elements.composerSend.disabled = true;
|
|
455
|
+
elements.composerInput.disabled = true;
|
|
456
|
+
phone.setComposerText(text);
|
|
457
|
+
state.actionBusy = true;
|
|
458
|
+
try {
|
|
459
|
+
const result = await phone.send();
|
|
460
|
+
state.revision = result.revision;
|
|
461
|
+
showMessage(elements.composerMessage, "");
|
|
462
|
+
showConversationRefreshStatus();
|
|
463
|
+
} catch (error) {
|
|
464
|
+
showMessage(elements.composerMessage, error.message, true);
|
|
465
|
+
} finally {
|
|
466
|
+
state.actionBusy = false;
|
|
467
|
+
elements.composerSend.disabled = false;
|
|
468
|
+
elements.composerInput.disabled = false;
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
documentRef.addEventListener("visibilitychange", () => {
|
|
472
|
+
if (documentRef.visibilityState === "visible") return startPolling();
|
|
473
|
+
stopPolling();
|
|
474
|
+
return undefined;
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
const ready = documentRef.visibilityState === "visible"
|
|
478
|
+
? startPolling()
|
|
479
|
+
: Promise.resolve();
|
|
480
|
+
return { ready, refreshWorkspace, startPolling, stopPolling };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function initializeSetup({ documentRef = document, fetchFn = fetch } = {}) {
|
|
484
|
+
const elements = {
|
|
485
|
+
complete: documentRef.querySelector("#setup-complete"),
|
|
486
|
+
dragHelp: documentRef.querySelector("#drag-help"),
|
|
487
|
+
loadWindow: documentRef.querySelector("#load-window"),
|
|
488
|
+
message: documentRef.querySelector("#setup-message"),
|
|
489
|
+
overlays: documentRef.querySelector("#region-overlays"),
|
|
490
|
+
refreshWindows: documentRef.querySelector("#refresh-windows"),
|
|
491
|
+
regionChoice: documentRef.querySelector("#region-choice"),
|
|
492
|
+
saveCalibration: documentRef.querySelector("#save-calibration"),
|
|
493
|
+
screenshot: documentRef.querySelector("#window-screenshot"),
|
|
494
|
+
stage: documentRef.querySelector("#screenshot-stage"),
|
|
495
|
+
windowSelect: documentRef.querySelector("#window-select"),
|
|
496
|
+
};
|
|
497
|
+
const state = {
|
|
498
|
+
drag: null,
|
|
499
|
+
phoneUrl: null,
|
|
500
|
+
selectedWindow: null,
|
|
501
|
+
status: null,
|
|
502
|
+
surfaces: null,
|
|
503
|
+
windows: [],
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
function showMessage(message, error = false) {
|
|
507
|
+
elements.message.textContent = message;
|
|
508
|
+
elements.message.classList.toggle("error", error);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function selectedRegion() {
|
|
512
|
+
return documentRef.querySelector('input[name="region"]:checked').value;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function pointFor(event) {
|
|
516
|
+
const box = elements.screenshot.getBoundingClientRect();
|
|
517
|
+
return { x: event.clientX - box.left, y: event.clientY - box.top };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function currentRectangle() {
|
|
521
|
+
if (!state.drag) return null;
|
|
522
|
+
const box = elements.screenshot.getBoundingClientRect();
|
|
523
|
+
return normalizeRectangle(state.drag.start, state.drag.end, box.width, box.height);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function renderRegions() {
|
|
527
|
+
if (!state.surfaces) return;
|
|
528
|
+
elements.overlays.replaceChildren();
|
|
529
|
+
const active = selectedRegion();
|
|
530
|
+
const drawing = currentRectangle();
|
|
531
|
+
for (const name of regionNames) {
|
|
532
|
+
const surface = name === active && drawing ? drawing : state.surfaces[name];
|
|
533
|
+
if (!surface) continue;
|
|
534
|
+
const overlay = documentRef.createElement("div");
|
|
535
|
+
overlay.className = `region-overlay region-overlay--${name}${name === active ? " selected" : ""}`;
|
|
536
|
+
overlay.style.left = `${surface.x * 100}%`;
|
|
537
|
+
overlay.style.top = `${surface.y * 100}%`;
|
|
538
|
+
overlay.style.width = `${surface.width * 100}%`;
|
|
539
|
+
overlay.style.height = `${surface.height * 100}%`;
|
|
540
|
+
const label = documentRef.createElement("span");
|
|
541
|
+
label.textContent = setupDiagramLabels[regionNames.indexOf(name)];
|
|
542
|
+
overlay.append(label);
|
|
543
|
+
elements.overlays.append(overlay);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
async function request(path, options = {}) {
|
|
548
|
+
const response = await fetchFn(path, options);
|
|
549
|
+
if (!response.ok) {
|
|
550
|
+
let message = `Request failed (${response.status}).`;
|
|
551
|
+
try { message = (await response.json()).error.message; } catch (_) { /* use status */ }
|
|
552
|
+
throw new Error(message);
|
|
553
|
+
}
|
|
554
|
+
return response.headers.get("content-type")?.includes("application/json")
|
|
555
|
+
? response.json()
|
|
556
|
+
: response;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
async function loadScreenshot(revision) {
|
|
560
|
+
elements.stage.hidden = false;
|
|
561
|
+
elements.dragHelp.hidden = false;
|
|
562
|
+
elements.screenshot.src = `/api/screenshot/window?revision=${encodeURIComponent(revision)}`;
|
|
563
|
+
await elements.screenshot.decode();
|
|
564
|
+
renderRegions();
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
async function selectWindow() {
|
|
568
|
+
const windowId = elements.windowSelect.value;
|
|
569
|
+
const selected = state.windows.find((candidate) => candidate.id === windowId);
|
|
570
|
+
if (!selected) return;
|
|
571
|
+
elements.loadWindow.disabled = true;
|
|
572
|
+
elements.saveCalibration.disabled = true;
|
|
573
|
+
showMessage("Loading the selected window…");
|
|
574
|
+
try {
|
|
575
|
+
const result = await request("/api/select", jsonRequest("POST", { window_id: windowId }));
|
|
576
|
+
state.selectedWindow = selected;
|
|
577
|
+
state.surfaces = createSetupModel(selected.surfaces, state.status).surfaces;
|
|
578
|
+
await loadScreenshot(result.revision);
|
|
579
|
+
elements.regionChoice.disabled = false;
|
|
580
|
+
elements.saveCalibration.disabled = false;
|
|
581
|
+
showMessage("Drag a rectangle for each area, then save all three together.");
|
|
582
|
+
} catch (error) {
|
|
583
|
+
showMessage(error.message, true);
|
|
584
|
+
} finally {
|
|
585
|
+
elements.loadWindow.disabled = !elements.windowSelect.value;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
async function loadWindows() {
|
|
590
|
+
elements.refreshWindows.disabled = true;
|
|
591
|
+
elements.loadWindow.disabled = true;
|
|
592
|
+
try {
|
|
593
|
+
const result = await request("/api/windows");
|
|
594
|
+
state.windows = result.windows;
|
|
595
|
+
elements.windowSelect.replaceChildren();
|
|
596
|
+
if (!state.windows.length) {
|
|
597
|
+
state.selectedWindow = null;
|
|
598
|
+
state.surfaces = null;
|
|
599
|
+
elements.windowSelect.disabled = true;
|
|
600
|
+
elements.regionChoice.disabled = true;
|
|
601
|
+
elements.saveCalibration.disabled = true;
|
|
602
|
+
elements.stage.hidden = true;
|
|
603
|
+
elements.dragHelp.hidden = true;
|
|
604
|
+
elements.refreshWindows.hidden = false;
|
|
605
|
+
showMessage("No compatible agent windows are open. Open one, then select Refresh windows.", true);
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
elements.refreshWindows.hidden = true;
|
|
610
|
+
const current = state.windows.find((candidate) => candidate.current);
|
|
611
|
+
if (!current) {
|
|
612
|
+
const placeholder = documentRef.createElement("option");
|
|
613
|
+
placeholder.value = "";
|
|
614
|
+
placeholder.textContent = "Choose a window…";
|
|
615
|
+
placeholder.disabled = true;
|
|
616
|
+
elements.windowSelect.append(placeholder);
|
|
617
|
+
}
|
|
618
|
+
for (const candidate of state.windows) {
|
|
619
|
+
const option = documentRef.createElement("option");
|
|
620
|
+
option.value = candidate.id;
|
|
621
|
+
const pieces = candidate.process_path.split(/[\\\\/]/);
|
|
622
|
+
option.textContent = `${candidate.agent_id} — ${candidate.title} (${pieces.at(-1)})`;
|
|
623
|
+
elements.windowSelect.append(option);
|
|
624
|
+
}
|
|
625
|
+
elements.windowSelect.disabled = false;
|
|
626
|
+
if (current) {
|
|
627
|
+
elements.windowSelect.value = current.id;
|
|
628
|
+
elements.loadWindow.disabled = false;
|
|
629
|
+
state.selectedWindow = current;
|
|
630
|
+
state.surfaces = createSetupModel(current.surfaces, state.status).surfaces;
|
|
631
|
+
elements.regionChoice.disabled = false;
|
|
632
|
+
elements.saveCalibration.disabled = false;
|
|
633
|
+
await loadScreenshot(state.status.revision);
|
|
634
|
+
showMessage("Current calibration loaded. Select another window and choose Load window only to change targets.");
|
|
635
|
+
} else {
|
|
636
|
+
elements.windowSelect.value = state.windows[0].id;
|
|
637
|
+
elements.loadWindow.disabled = false;
|
|
638
|
+
showMessage("Select Load window to begin calibration.");
|
|
639
|
+
}
|
|
640
|
+
} catch (error) {
|
|
641
|
+
showMessage(error.message, true);
|
|
642
|
+
} finally {
|
|
643
|
+
elements.refreshWindows.disabled = false;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
async function initialize() {
|
|
648
|
+
try {
|
|
649
|
+
state.status = await request("/api/status");
|
|
650
|
+
state.phoneUrl = phoneUrlForStatus(state.status);
|
|
651
|
+
await loadWindows();
|
|
652
|
+
} catch (error) {
|
|
653
|
+
showMessage(error.message, true);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
elements.loadWindow.addEventListener("click", selectWindow);
|
|
658
|
+
elements.refreshWindows.addEventListener("click", loadWindows);
|
|
659
|
+
elements.windowSelect.addEventListener("change", () => {
|
|
660
|
+
elements.loadWindow.disabled = !state.windows.some(
|
|
661
|
+
(candidate) => candidate.id === elements.windowSelect.value,
|
|
662
|
+
);
|
|
663
|
+
});
|
|
664
|
+
elements.regionChoice.addEventListener("change", renderRegions);
|
|
665
|
+
elements.screenshot.addEventListener("load", renderRegions);
|
|
666
|
+
elements.stage.addEventListener("pointerdown", (event) => {
|
|
667
|
+
if (!state.surfaces || !elements.screenshot.complete) return;
|
|
668
|
+
event.preventDefault();
|
|
669
|
+
const point = pointFor(event);
|
|
670
|
+
state.drag = { start: point, end: point };
|
|
671
|
+
elements.stage.setPointerCapture(event.pointerId);
|
|
672
|
+
renderRegions();
|
|
673
|
+
});
|
|
674
|
+
elements.stage.addEventListener("pointermove", (event) => {
|
|
675
|
+
if (!state.drag) return;
|
|
676
|
+
state.drag.end = pointFor(event);
|
|
677
|
+
renderRegions();
|
|
678
|
+
});
|
|
679
|
+
elements.stage.addEventListener("pointerup", (event) => {
|
|
680
|
+
if (!state.drag) return;
|
|
681
|
+
state.drag.end = pointFor(event);
|
|
682
|
+
const rectangle = currentRectangle();
|
|
683
|
+
state.drag = null;
|
|
684
|
+
if (rectangle.width < minimumRegionSize || rectangle.height < minimumRegionSize) {
|
|
685
|
+
showMessage("That area is too small. Drag an area at least 1% of the screenshot wide and tall.", true);
|
|
686
|
+
} else {
|
|
687
|
+
state.surfaces[selectedRegion()] = rectangle;
|
|
688
|
+
showMessage("Area updated. Save when all three rectangles look right.");
|
|
689
|
+
}
|
|
690
|
+
renderRegions();
|
|
691
|
+
});
|
|
692
|
+
elements.stage.addEventListener("pointercancel", () => {
|
|
693
|
+
state.drag = null;
|
|
694
|
+
renderRegions();
|
|
695
|
+
});
|
|
696
|
+
elements.saveCalibration.addEventListener("click", async () => {
|
|
697
|
+
elements.saveCalibration.disabled = true;
|
|
698
|
+
showMessage("Saving calibration…");
|
|
699
|
+
try {
|
|
700
|
+
await request("/api/calibration", calibrationRequest(state.surfaces));
|
|
701
|
+
const phoneUrl = state.phoneUrl;
|
|
702
|
+
elements.complete.replaceChildren(
|
|
703
|
+
"Saved. On your phone, open ",
|
|
704
|
+
phoneUrl,
|
|
705
|
+
". Then use the ",
|
|
706
|
+
);
|
|
707
|
+
const link = documentRef.createElement("a");
|
|
708
|
+
link.href = "/";
|
|
709
|
+
link.textContent = "CodeAway controls";
|
|
710
|
+
elements.complete.append(link, ".");
|
|
711
|
+
elements.complete.hidden = false;
|
|
712
|
+
showMessage("Calibration saved.");
|
|
713
|
+
} catch (error) {
|
|
714
|
+
showMessage(error.message, true);
|
|
715
|
+
} finally {
|
|
716
|
+
elements.saveCalibration.disabled = false;
|
|
717
|
+
}
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
return { ready: initialize(), loadWindows, selectWindow };
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
if (typeof module !== "undefined" && module.exports) {
|
|
724
|
+
module.exports = {
|
|
725
|
+
calibrationRequest,
|
|
726
|
+
createPhoneController,
|
|
727
|
+
createSetupModel,
|
|
728
|
+
initializePhoneWorkspace,
|
|
729
|
+
initializeSetup,
|
|
730
|
+
normalizeRectangle,
|
|
731
|
+
phoneUrlForStatus,
|
|
732
|
+
pointToFraction,
|
|
733
|
+
setupDiagramLabels,
|
|
734
|
+
swipeToSteps,
|
|
735
|
+
toggleProject,
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
if (typeof document !== "undefined") {
|
|
740
|
+
document.addEventListener("DOMContentLoaded", () => {
|
|
741
|
+
if (document.querySelector("#phone-workspace")) {
|
|
742
|
+
initializePhoneWorkspace();
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
initializeSetup();
|
|
746
|
+
});
|
|
747
|
+
}
|