Make ticket pages open in T3 Code's file viewer, and show plans, mocks and progress inline in the thread

This commit is contained in:
Gabriel Brown committed 2026-10-30 14:58:46 -04:00
1 parent e21a4c24f0
commit 4e39be860c
9 files changed
+235 -42

No files matched your search

@@ -1,11 +1,11 @@
// ticket-page: the only writer of a ticket's page data. See ../SITE.md for when the
// ticket skill calls each command. Run `ticket-page help` for the command list.
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join, relative, resolve } from "node:path";
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { htmlToText, importIssue, type JiraIssue, scanResources } from "./jira-import";
import { renderView, VIEWS } from "./thread-view";
import {
type Criterion, type Epic, type Finding, type Mock, now, type Plan, progress, readData, type Step,
storyStatus, type Ticket, writeData,
@@ -49,24 +49,19 @@ function ticketDir(key: string): string {
const ticketFile = (dir: string) => join(dir, "ticket.data.js");
const epicFile = (epicKey: string) => join(docsRoot(), epicKey, "epic.data.js");
/** Copies the shared renderer into _site/ when the skill's copy changed, and writes the
* page shell. Run on every write so an old ticket picks up the current renderer. */
/** Writes the page shell with the renderer's CSS and JS inlined, so the page carries
* everything but its own data file. T3 Code's file viewer serves an HTML file's assets only
* from its own folder and below, so a shell that pointed at a shared ../../_site/ renderer
* rendered blank there. Run on every write so an old ticket picks up the current renderer. */
function refreshSite(pageDir: string, kind: "ticket" | "epic", title: string) {
const site = join(docsRoot(), "_site");
mkdirSync(site, { recursive: true });
const version = createHash("sha1");
for (const f of ["site.css", "site.js"]) {
const from = join(SITE_TEMPLATES, f), to = join(site, f);
const body = readFileSync(from, "utf8");
version.update(body);
if (!existsSync(to) || readFileSync(to, "utf8") !== body) copyFileSync(from, to);
}
// The shell names the renderer by content hash, so a browser that cached an older
// renderer loads the new one the next time the page opens.
const shell = readFileSync(join(SITE_TEMPLATES, `${kind}.html`), "utf8")
.replaceAll("{{SITE}}", relative(pageDir, site) || ".")
.replaceAll("{{VERSION}}", version.digest("hex").slice(0, 10))
.replaceAll("{{TITLE}}", title.replace(/[<&]/g, c => (c === "<" ? "&lt;" : "&amp;")));
const fill: Record<string, string> = {
CSS: readFileSync(join(SITE_TEMPLATES, "site.css"), "utf8"),
JS: readFileSync(join(SITE_TEMPLATES, "site.js"), "utf8"),
TITLE: title.replace(/[<&]/g, c => (c === "<" ? "&lt;" : "&amp;")),
};
// A replacer function, because the renderer's own source is full of `$` sequences that
// a replacement string would read as patterns.
const shell = readFileSync(join(SITE_TEMPLATES, `${kind}.html`), "utf8").replace(/\{\{(CSS|JS|TITLE)\}\}/g, (_, k: string) => fill[k]);
const page = join(pageDir, "index.html");
if (!existsSync(page) || readFileSync(page, "utf8") !== shell) writeFileSync(page, shell);
}
@@ -342,8 +337,24 @@ command("show", "show <KEY> print where the ticket stands", a => {
command("path", "path <KEY> print the page path", a => console.log(join(ticketDir(a.pos[0]), "index.html")));
command("open", "open <KEY> open the page in the browser", a => {
spawnSync("xdg-open", [join(ticketDir(a.pos[0]), "index.html")], { stdio: "ignore" });
command("open", "open <KEY> print the page path, and open it in the browser outside T3 Code", a => {
const { dir, t } = load(a.pos[0]);
// Rewrites the shells first, so a page last written before the renderer was inlined
// opens in T3 too.
refreshSite(dir, "ticket", `${t.key}: ${t.title}`);
const epic = t.epic && epicFile(t.epic.key);
if (epic && existsSync(epic)) { const e = readData(epic, "epic"); refreshSite(dirname(epic), "epic", `${e.key}: ${e.title}`); }
const page = join(dir, "index.html");
// Inside a T3 Code thread the agent links this path and the page opens in T3's file
// viewer, beside the conversation. A browser window would be one more thing to juggle.
if (!process.env.T3CODE_THREAD_ID) spawnSync("xdg-open", [page], { stdio: "ignore" });
console.log(page);
});
command("view", `view <KEY> <${VIEWS.join("|")}> [--mock <id>] print a thread view for T3 Code's html_render`, a => {
const [key, name] = a.pos;
const view = VIEWS.find(v => v === name) ?? fail(`view takes ${VIEWS.join(", ")}`);
console.log(renderView(load(key).t, view, a.one("mock")));
});
command("md", "md <KEY> print the ticket and plan as markdown, for an agent's reading order", a => {