Add expose_port previews with a Preview tab; leave setup to AGENTS.md
This commit is contained in:
+52
-2
@@ -475,6 +475,25 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/tasks/{taskId}/previews": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
taskId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
/** @description The ports the task's agent exposed with expose_port, by name. Each is served by tailscale serve on the same port number of byre's host, over HTTPS, until the task is merged or discarded. */
|
||||
get: operations["listPreviews"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/tasks/{taskId}/snapshots": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -793,7 +812,7 @@ export interface components {
|
||||
branch: string;
|
||||
worktree_path: string;
|
||||
base_ref: string;
|
||||
/** @description The first of the task's block of ports (port_base to port_base+9), passed to its setup script and agent as PORT. */
|
||||
/** @description The first of the task's block of ports (port_base to port_base+9), passed to its agent and terminal as PORT. */
|
||||
port_base: number;
|
||||
/** @enum {string} */
|
||||
status: "active" | "merged" | "discarded";
|
||||
@@ -933,6 +952,14 @@ export interface components {
|
||||
/** Format: date-time */
|
||||
created_at: string;
|
||||
};
|
||||
Preview: {
|
||||
task_id: string;
|
||||
name: string;
|
||||
/** @description A port in the task's block, served at https://<byre's host>:<port>/. */
|
||||
port: number;
|
||||
/** Format: date-time */
|
||||
created_at: string;
|
||||
};
|
||||
AgentSession: {
|
||||
id: string;
|
||||
/** @description Empty for a lead session. */
|
||||
@@ -1076,7 +1103,7 @@ export interface components {
|
||||
focused: boolean;
|
||||
};
|
||||
/** @enum {string} */
|
||||
TaskTab: "transcript" | "changes" | "files" | "terminal" | "inbox" | "project";
|
||||
TaskTab: "transcript" | "changes" | "files" | "terminal" | "preview" | "inbox" | "project";
|
||||
TerminalSize: {
|
||||
cols: number;
|
||||
rows: number;
|
||||
@@ -1951,6 +1978,29 @@ export interface operations {
|
||||
409: components["responses"]["Error"];
|
||||
};
|
||||
};
|
||||
listPreviews: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
taskId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description The previews. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Preview"][];
|
||||
};
|
||||
};
|
||||
404: components["responses"]["Error"];
|
||||
};
|
||||
};
|
||||
listSnapshots: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { mountInbox } from "./inbox.js";
|
||||
import { mountLead } from "./lead.js";
|
||||
import { openSwitcher } from "./navbar.js";
|
||||
import { findTask, focusedProject, focusTab, on, onEach, state, tabs } from "./state.js";
|
||||
import { mountPreview } from "./preview.js";
|
||||
import { mountProject } from "./project.js";
|
||||
import { mountTranscript } from "./task.js";
|
||||
import { mountTerminal } from "./terminal.js";
|
||||
@@ -15,6 +16,7 @@ const views: Record<TaskTab, { label: string; mount: (root: HTMLElement) => () =
|
||||
changes: { label: "Changes", mount: (root) => mountChanges(root) },
|
||||
files: { label: "Files", mount: mountFiles },
|
||||
terminal: { label: "Terminal", mount: mountTerminal },
|
||||
preview: { label: "Preview", mount: mountPreview },
|
||||
inbox: { label: "Inbox", mount: mountInbox },
|
||||
project: { label: "Project", mount: mountProject },
|
||||
};
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { get, type ByreEvent, type Schemas } from "./api.js";
|
||||
import { errorText, h, replace } from "./dom.js";
|
||||
import { onEach, state } from "./state.js";
|
||||
|
||||
type Preview = Schemas["Preview"];
|
||||
|
||||
const chosen = new Map<string, string>();
|
||||
|
||||
export function previewURL(port: number): string {
|
||||
return `https://${location.hostname}:${port}/`;
|
||||
}
|
||||
|
||||
export function mountPreview(root: HTMLElement): () => void {
|
||||
const head = h("header", { class: "preview-header" });
|
||||
const body = h("div", { class: "preview-body" });
|
||||
let previews: Preview[] | undefined;
|
||||
let error = "";
|
||||
let target = 0;
|
||||
let framed = "";
|
||||
let disposed = false;
|
||||
|
||||
const current = (): Preview | undefined => {
|
||||
const taskId = state.selectedTaskId;
|
||||
const name = taskId ? chosen.get(taskId) : undefined;
|
||||
return previews?.find((p) => p.name === name) ?? previews?.[0];
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
const taskId = state.selectedTaskId;
|
||||
if (!taskId) {
|
||||
render();
|
||||
return;
|
||||
}
|
||||
const started = ++target;
|
||||
try {
|
||||
const fresh = await get("/api/tasks/{taskId}/previews", { path: { taskId } });
|
||||
if (started === target) {
|
||||
previews = fresh;
|
||||
error = "";
|
||||
}
|
||||
} catch (err) {
|
||||
if (started === target) {
|
||||
previews = undefined;
|
||||
error = errorText(err);
|
||||
}
|
||||
}
|
||||
if (!disposed && started === target) {
|
||||
render();
|
||||
}
|
||||
};
|
||||
|
||||
const render = () => {
|
||||
const taskId = state.selectedTaskId;
|
||||
const shown = current();
|
||||
replace(
|
||||
head,
|
||||
h(
|
||||
"nav",
|
||||
{ class: "preview-names" },
|
||||
...(previews ?? []).map((p) => {
|
||||
const b = h("button", { type: "button", class: p === shown ? "preview-name active" : "preview-name" }, p.name, h("span", { class: "preview-port" }, ` :${p.port}`));
|
||||
b.onclick = () => {
|
||||
if (taskId) {
|
||||
chosen.set(taskId, p.name);
|
||||
}
|
||||
render();
|
||||
};
|
||||
return b;
|
||||
}),
|
||||
),
|
||||
shown ? h("a", { class: "preview-open", href: previewURL(shown.port), target: "_blank", rel: "noopener" }, "open ↗") : null,
|
||||
);
|
||||
if (!taskId) {
|
||||
framed = "";
|
||||
replace(body, h("div", { class: "empty" }, "No task selected."));
|
||||
return;
|
||||
}
|
||||
if (error) {
|
||||
framed = "";
|
||||
replace(body, h("div", { class: "empty error-text" }, error));
|
||||
return;
|
||||
}
|
||||
if (!previews) {
|
||||
framed = "";
|
||||
replace(body, h("div", { class: "empty" }, "Loading…"));
|
||||
return;
|
||||
}
|
||||
if (!shown) {
|
||||
framed = "";
|
||||
replace(body, h("div", { class: "empty" }, "Nothing exposed. The task's agent shows a running server here with expose_port."));
|
||||
return;
|
||||
}
|
||||
const url = previewURL(shown.port);
|
||||
if (url === framed) {
|
||||
return;
|
||||
}
|
||||
framed = url;
|
||||
replace(body, h("iframe", { class: "preview-frame", src: url, title: shown.name }));
|
||||
};
|
||||
|
||||
const offs = [
|
||||
onEach(["selection"], () => {
|
||||
target++;
|
||||
previews = undefined;
|
||||
error = "";
|
||||
render();
|
||||
void load();
|
||||
}),
|
||||
onEach(["transcript"], (fresh) => {
|
||||
if ((fresh as ByreEvent[]).some((ev) => ev.kind === "preview.exposed")) {
|
||||
framed = "";
|
||||
void load();
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
replace(root, h("div", { class: "preview-view" }, head, body));
|
||||
render();
|
||||
void load();
|
||||
return () => {
|
||||
disposed = true;
|
||||
for (const off of offs) {
|
||||
off();
|
||||
}
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -17,7 +17,7 @@ export type Topic =
|
||||
| "lead.transcript"
|
||||
| "lead.stream";
|
||||
|
||||
export const tabs: TaskTab[] = ["transcript", "changes", "files", "terminal", "inbox", "project"];
|
||||
export const tabs: TaskTab[] = ["transcript", "changes", "files", "terminal", "preview", "inbox", "project"];
|
||||
|
||||
export const state = {
|
||||
me: undefined as User | undefined,
|
||||
|
||||
+4
-10
@@ -1,6 +1,7 @@
|
||||
import { get, post, type AgentSession, type ByreEvent, type Participant, type Snapshot } from "./api.js";
|
||||
import { ago, errorText, h, replace, showAlert, showConfirm } from "./dom.js";
|
||||
import { markdown } from "./markdown.js";
|
||||
import { previewURL } from "./preview.js";
|
||||
import { currentSession, findTask, focusTab, focusTask, onEach, pinSnapshot, setFocus, state, type Topic } from "./state.js";
|
||||
|
||||
export type Usage = { used: number; window: number; model: string };
|
||||
@@ -1006,16 +1007,9 @@ function renderEvent(ev: ByreEvent, ctx: Ctx): HTMLElement | null {
|
||||
case "task.merge_queued":
|
||||
return h("div", { class: "note" }, `${ev.actor} asked to merge; held until the merge in progress in this project is done `, when);
|
||||
|
||||
case "setup.started": {
|
||||
const base = Number(p["port_base"]);
|
||||
return h("div", { class: "note" }, `running ${String(p["script"])} with ports ${base}–${base + 9} `, when);
|
||||
}
|
||||
|
||||
case "setup.ended": {
|
||||
const secs = Math.round(Number(p["duration_ms"]) / 1000);
|
||||
const failed = typeof p["error"] === "string";
|
||||
const summary = failed ? `${String(p["script"])} failed after ${secs}s: ${String(p["error"])}` : `${String(p["script"])} finished in ${secs}s`;
|
||||
return h("details", { class: failed ? "note error" : "note" }, h("summary", {}, summary, " ", when), h("pre", {}, String(p["output"] ?? "")));
|
||||
case "preview.exposed": {
|
||||
const url = previewURL(Number(p["port"]));
|
||||
return h("div", { class: "note" }, `${ev.actor} exposed "${String(p["name"])}" at `, h("a", { href: url, target: "_blank", rel: "noopener" }, url), " ", when);
|
||||
}
|
||||
|
||||
case "task.merged":
|
||||
|
||||
Reference in New Issue
Block a user