mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-26 10:45:03 +00:00
0976c94103
* Add worktree context collector Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2d224e01-e53f-46f8-beb2-0b8b64c5ee89 * Fix Where Was I worktree state Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2d224e01-e53f-46f8-beb2-0b8b64c5ee89 * Collect git graph and change details Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2d224e01-e53f-46f8-beb2-0b8b64c5ee89 * Add worktree git graph Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2d224e01-e53f-46f8-beb2-0b8b64c5ee89 * Add uncommitted change inspector Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2d224e01-e53f-46f8-beb2-0b8b64c5ee89 * Release Where Was I 1.1.0 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2d224e01-e53f-46f8-beb2-0b8b64c5ee89 * Normalize diff paths on Windows Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2d224e01-e53f-46f8-beb2-0b8b64c5ee89 * Fix change inspector clicks Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2d224e01-e53f-46f8-beb2-0b8b64c5ee89 * Collapse base branch graph history Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2d224e01-e53f-46f8-beb2-0b8b64c5ee89 * Keep diff drawer across context renders Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2d224e01-e53f-46f8-beb2-0b8b64c5ee89 * Fix Where Was I review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c161d734-edb5-4707-a48c-966d4e83a2cf * Address follow-up Where Was I review Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c161d734-edb5-4707-a48c-966d4e83a2cf * Harden Where Was I canvas against title injection and fix graph/diff edge cases - Remove inline onclick handlers; PR/issue cards are buttons wired with addEventListener from data, the refresh/resume buttons use ids, and the page is served with a nonce-based CSP (script-src 'nonce-...', default-src 'none'). escapeHtml now also escapes apostrophes. - Collapse only the graph suffix after the final branch commit (splitCommitGraph, computed server-side as baseGraphStart) so --topo-order interleaving never hides worktree commits. - Preserve the selected status record through the UI, /file-diff, and get_file_diff so a path that appears twice (e.g. staged deletion + untracked re-creation) previews the right record. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 18350039-5e2b-40f0-b537-bc22cabcbb1b --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2d224e01-e53f-46f8-beb2-0b8b64c5ee89 Copilot-Session: c161d734-edb5-4707-a48c-966d4e83a2cf Copilot-Session: 18350039-5e2b-40f0-b537-bc22cabcbb1b
1286 lines
42 KiB
JavaScript
1286 lines
42 KiB
JavaScript
// Extension: where-was-i
|
||
// Interrupt Recovery canvas — helps developers resume mental context after interruption.
|
||
|
||
import { createServer } from "node:http";
|
||
import { execFile } from "node:child_process";
|
||
import { randomBytes, timingSafeEqual } from "node:crypto";
|
||
import { writeFile, mkdir } from "node:fs/promises";
|
||
import { join } from "node:path";
|
||
import { joinSession, createCanvas, CanvasError } from "@github/copilot-sdk/extension";
|
||
import { gatherGitContext, getFileDiff } from "./git-context.mjs";
|
||
|
||
const servers = new Map();
|
||
const sseClients = new Map(); // instanceId → Set<res>
|
||
const contextCache = new Map(); // instanceId → contextData
|
||
|
||
let workspaceCwd = null;
|
||
|
||
function captureCwd(ctx) {
|
||
const dir = ctx?.session?.workingDirectory;
|
||
if (typeof dir === "string" && dir.trim()) workspaceCwd = dir;
|
||
}
|
||
|
||
async function activeCwd(ctx) {
|
||
captureCwd(ctx);
|
||
if (!workspaceCwd && sessionRef) {
|
||
const snapshot = await sessionRef.rpc.metadata.snapshot();
|
||
const dir = snapshot?.workingDirectory;
|
||
if (typeof dir === "string" && dir.trim()) workspaceCwd = dir;
|
||
}
|
||
if (!workspaceCwd) {
|
||
throw new CanvasError(
|
||
"workspace_unavailable",
|
||
"No repository working directory is attached to this session.",
|
||
);
|
||
}
|
||
return workspaceCwd;
|
||
}
|
||
|
||
function runGhJson(cwd, args) {
|
||
return new Promise((resolve, reject) => {
|
||
execFile("gh", args, { cwd, timeout: 15000, maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => {
|
||
if (error) {
|
||
reject(new Error((stderr || error.message || "GitHub CLI command failed").trim()));
|
||
return;
|
||
}
|
||
try {
|
||
resolve({
|
||
data: JSON.parse(stdout || "[]"),
|
||
warning: (stderr || "").trim(),
|
||
});
|
||
} catch (parseError) {
|
||
reject(new Error(`GitHub CLI returned invalid JSON: ${parseError.message}`));
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
async function gatherContext(cwd) {
|
||
const gitContext = await gatherGitContext(cwd);
|
||
const [prs, issues] = await Promise.allSettled([
|
||
runGhJson(cwd, [
|
||
"pr", "list", "--author=@me", "--state=open", "--limit=10",
|
||
"--json", "number,title,url,updatedAt,comments",
|
||
]),
|
||
runGhJson(cwd, [
|
||
"issue", "list", "--assignee=@me", "--state=open", "--limit=10",
|
||
"--json", "number,title,url,updatedAt",
|
||
]),
|
||
]);
|
||
|
||
return {
|
||
...gitContext,
|
||
openPrs: prs.status === "fulfilled" ? prs.value.data : [],
|
||
assignedIssues: issues.status === "fulfilled" ? issues.value.data : [],
|
||
warnings: [prs, issues]
|
||
.map((result) => result.status === "fulfilled" ? result.value.warning : result.reason.message)
|
||
.filter(Boolean),
|
||
gatheredAt: new Date().toISOString(),
|
||
};
|
||
}
|
||
|
||
// --- Persistence ---
|
||
|
||
async function saveContext(workspacePath, data) {
|
||
if (!workspacePath) return;
|
||
const dir = join(workspacePath, "files");
|
||
await mkdir(dir, { recursive: true });
|
||
await writeFile(join(dir, "where-was-i-context.json"), JSON.stringify(data, null, 2));
|
||
}
|
||
|
||
// --- SSE ---
|
||
|
||
function broadcast(instanceId, data) {
|
||
const clients = sseClients.get(instanceId);
|
||
if (!clients) return;
|
||
const payload = `data: ${JSON.stringify(data)}\n\n`;
|
||
for (const res of clients) {
|
||
try {
|
||
res.write(payload);
|
||
} catch {
|
||
clients.delete(res);
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- HTML renderer ---
|
||
|
||
function renderHtml(instanceId, scriptNonce) {
|
||
return `<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<title>Where Was I?</title>
|
||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||
<style>
|
||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||
|
||
:root {
|
||
--bg: #f8fcff;
|
||
--surface: #ffffff;
|
||
--text: #111827;
|
||
--muted: #6b7280;
|
||
--meta: #94a3b8;
|
||
--border: #e2e8f0;
|
||
--coral: #ff7f50;
|
||
--azure: #0ea5e9;
|
||
--sage: #84cc16;
|
||
--coral-tint: #fff0eb;
|
||
--azure-tint: #e8f7fe;
|
||
--sage-tint: #f2fde0;
|
||
--sans: 'DM Sans', system-ui, sans-serif;
|
||
--mono: 'IBM Plex Mono', 'SF Mono', monospace;
|
||
--radius-soft: 16px;
|
||
--radius-compact: 8px;
|
||
--radius-pill: 9999px;
|
||
}
|
||
|
||
html, body {
|
||
font-family: var(--sans);
|
||
font-size: 15px;
|
||
line-height: 1.7;
|
||
color: var(--text);
|
||
background: var(--bg);
|
||
-webkit-font-smoothing: antialiased;
|
||
}
|
||
|
||
body { padding: 2rem 1.5rem 3rem; max-width: 880px; margin: 0 auto; }
|
||
|
||
.header {
|
||
margin-bottom: 2.5rem;
|
||
display: flex;
|
||
align-items: baseline;
|
||
justify-content: space-between;
|
||
flex-wrap: wrap;
|
||
gap: 1rem;
|
||
}
|
||
|
||
.header h1 {
|
||
font-size: 1.75rem;
|
||
font-weight: 700;
|
||
letter-spacing: -0.03em;
|
||
color: var(--text);
|
||
}
|
||
|
||
.time-away {
|
||
font-family: var(--mono);
|
||
font-size: 0.8rem;
|
||
font-weight: 500;
|
||
color: var(--muted);
|
||
background: var(--azure-tint);
|
||
padding: 6px 14px;
|
||
border-radius: var(--radius-pill);
|
||
border: 1px solid rgba(14,165,233,0.12);
|
||
}
|
||
|
||
.branch-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.75rem;
|
||
margin-bottom: 2rem;
|
||
padding: 12px 16px;
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-compact);
|
||
}
|
||
|
||
.branch-bar .icon { font-size: 1.1rem; }
|
||
.branch-bar .branch-name {
|
||
font-family: var(--mono);
|
||
font-size: 0.85rem;
|
||
font-weight: 500;
|
||
color: var(--azure);
|
||
}
|
||
.branch-bar .worktree-name {
|
||
font-family: var(--mono);
|
||
font-size: 0.8rem;
|
||
color: var(--text);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.branch-bar .divider {
|
||
width: 1px;
|
||
height: 20px;
|
||
background: var(--border);
|
||
}
|
||
.branch-bar .divergence {
|
||
margin-left: auto;
|
||
font-family: var(--mono);
|
||
font-size: 0.75rem;
|
||
color: var(--muted);
|
||
}
|
||
.branch-bar .label {
|
||
font-size: 0.75rem;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.7px;
|
||
color: var(--meta);
|
||
}
|
||
|
||
.section { margin-bottom: 2rem; }
|
||
.section-title {
|
||
font-size: 0.7rem;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.8px;
|
||
color: var(--meta);
|
||
margin-bottom: 0.75rem;
|
||
padding-left: 2px;
|
||
}
|
||
|
||
.card {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-soft);
|
||
padding: 20px 24px;
|
||
margin-bottom: 0.75rem;
|
||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||
}
|
||
.card:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 4px 16px rgba(0,0,0,0.06);
|
||
}
|
||
|
||
.card-clickable { cursor: pointer; }
|
||
.card-clickable:active { transform: translateY(0); }
|
||
|
||
.commit-list { list-style: none; }
|
||
.commit-list li {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 0.75rem;
|
||
padding: 6px 0;
|
||
border-bottom: 1px solid rgba(0,0,0,0.03);
|
||
}
|
||
.commit-list li:last-child { border-bottom: none; }
|
||
.commit-hash {
|
||
font-family: var(--mono);
|
||
font-size: 0.78rem;
|
||
color: var(--azure);
|
||
flex-shrink: 0;
|
||
}
|
||
.commit-msg {
|
||
font-size: 0.88rem;
|
||
color: var(--text);
|
||
}
|
||
|
||
.git-graph {
|
||
overflow-x: auto;
|
||
}
|
||
.graph-row {
|
||
display: grid;
|
||
grid-template-columns: 72px 72px minmax(220px, 1fr) auto;
|
||
align-items: center;
|
||
min-height: 34px;
|
||
border-bottom: 1px solid color-mix(in srgb, var(--border) 65%, transparent);
|
||
}
|
||
.graph-row:last-child { border-bottom: none; }
|
||
.graph-row.worktree-commit {
|
||
background: color-mix(in srgb, var(--azure) 5%, transparent);
|
||
}
|
||
.graph-lines {
|
||
color: var(--azure);
|
||
font-family: var(--mono);
|
||
font-size: 0.9rem;
|
||
font-weight: 600;
|
||
white-space: pre;
|
||
}
|
||
.graph-hash {
|
||
color: var(--meta);
|
||
font-family: var(--mono);
|
||
font-size: 0.72rem;
|
||
}
|
||
.graph-subject {
|
||
color: var(--text);
|
||
font-size: 0.84rem;
|
||
overflow: hidden;
|
||
padding-right: 12px;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.graph-refs {
|
||
display: flex;
|
||
gap: 4px;
|
||
justify-content: flex-end;
|
||
white-space: nowrap;
|
||
}
|
||
.graph-ref {
|
||
background: var(--azure-tint);
|
||
border: 1px solid color-mix(in srgb, var(--azure) 20%, transparent);
|
||
border-radius: var(--radius-pill);
|
||
color: var(--azure);
|
||
font-family: var(--mono);
|
||
font-size: 0.65rem;
|
||
padding: 2px 7px;
|
||
}
|
||
.graph-history {
|
||
border-top: 1px solid var(--border);
|
||
margin-top: 4px;
|
||
}
|
||
.graph-history summary {
|
||
color: var(--muted);
|
||
cursor: pointer;
|
||
font-size: 0.75rem;
|
||
font-weight: 600;
|
||
list-style-position: inside;
|
||
padding: 12px 0 6px;
|
||
user-select: none;
|
||
}
|
||
.graph-history summary:hover { color: var(--azure); }
|
||
.graph-history[open] summary { margin-bottom: 4px; }
|
||
|
||
.file-list { list-style: none; }
|
||
.file-list li {
|
||
padding: 0;
|
||
}
|
||
.file-list .file-change {
|
||
align-items: center;
|
||
background: transparent;
|
||
border: 0;
|
||
border-radius: 6px;
|
||
color: var(--muted);
|
||
display: flex;
|
||
gap: 8px;
|
||
font-family: var(--mono);
|
||
font-size: 0.8rem;
|
||
text-align: left;
|
||
width: 100%;
|
||
padding: 7px 8px;
|
||
}
|
||
.file-list .file-change {
|
||
cursor: pointer;
|
||
transition: background 0.12s ease, color 0.12s ease;
|
||
}
|
||
.file-list .file-change:hover,
|
||
.file-list .file-change:focus-visible {
|
||
background: color-mix(in srgb, var(--azure) 7%, transparent);
|
||
color: var(--text);
|
||
outline: none;
|
||
}
|
||
.file-list .view-diff {
|
||
color: var(--meta);
|
||
font-family: var(--sans);
|
||
font-size: 0.7rem;
|
||
margin-left: auto;
|
||
}
|
||
.file-list .status-badge,
|
||
.diff-header .status-badge {
|
||
display: inline-block;
|
||
border-radius: var(--radius-pill);
|
||
font-weight: 600;
|
||
min-width: 76px;
|
||
padding: 2px 8px;
|
||
text-align: center;
|
||
}
|
||
.status-badge.modified { background: #fff7ed; color: #b45309; }
|
||
.status-badge.added { background: var(--sage-tint); color: #4d7c0f; }
|
||
.status-badge.deleted { background: #fef2f2; color: #dc2626; }
|
||
.status-badge.untracked { background: var(--coral-tint); color: #c2410c; }
|
||
.status-badge.renamed { background: var(--azure-tint); color: var(--azure); }
|
||
.status-badge.conflicted { background: #fef2f2; color: #b91c1c; }
|
||
.file-list .file-path {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.thread-cards { display: grid; grid-template-columns: 1fr; gap: 0.6rem; }
|
||
.thread-card {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.75rem;
|
||
padding: 14px 18px;
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-compact);
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
text-align: left;
|
||
transition: all 0.15s ease;
|
||
width: 100%;
|
||
}
|
||
.thread-card:hover {
|
||
border-color: var(--azure);
|
||
background: color-mix(in srgb, var(--azure) 4%, var(--surface));
|
||
}
|
||
.thread-card .number {
|
||
font-family: var(--mono);
|
||
font-size: 0.78rem;
|
||
font-weight: 500;
|
||
color: var(--azure);
|
||
flex-shrink: 0;
|
||
}
|
||
.thread-card .title {
|
||
font-size: 0.88rem;
|
||
color: var(--text);
|
||
flex: 1;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.thread-card .badge {
|
||
font-size: 0.68rem;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
padding: 3px 8px;
|
||
border-radius: var(--radius-pill);
|
||
flex-shrink: 0;
|
||
}
|
||
.badge-pr { background: var(--azure-tint); color: var(--azure); }
|
||
.badge-issue { background: var(--sage-tint); color: #4d7c0f; }
|
||
|
||
.resume-section {
|
||
margin-top: 2.5rem;
|
||
text-align: center;
|
||
}
|
||
|
||
.resume-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 14px 36px;
|
||
font-family: var(--sans);
|
||
font-size: 1rem;
|
||
font-weight: 600;
|
||
color: #fff;
|
||
background: var(--coral);
|
||
border: none;
|
||
border-radius: var(--radius-pill);
|
||
cursor: pointer;
|
||
box-shadow: 0 4px 12px rgba(255,127,80,0.3);
|
||
transition: all 0.2s ease;
|
||
}
|
||
.resume-btn:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 6px 20px rgba(255,127,80,0.4);
|
||
}
|
||
.resume-btn:active { transform: translateY(0); }
|
||
|
||
.resume-hint {
|
||
margin-top: 0.75rem;
|
||
font-size: 0.78rem;
|
||
color: var(--meta);
|
||
}
|
||
|
||
.empty-state {
|
||
color: var(--muted);
|
||
font-size: 0.88rem;
|
||
font-style: italic;
|
||
padding: 8px 0;
|
||
}
|
||
|
||
.warnings {
|
||
color: #b45309;
|
||
background: #fffbeb;
|
||
border: 1px solid #fde68a;
|
||
border-radius: var(--radius-compact);
|
||
padding: 10px 14px;
|
||
font-size: 0.78rem;
|
||
margin-bottom: 1.5rem;
|
||
}
|
||
|
||
.refresh-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 6px 14px;
|
||
font-family: var(--sans);
|
||
font-size: 0.75rem;
|
||
font-weight: 600;
|
||
color: var(--muted);
|
||
background: transparent;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-pill);
|
||
cursor: pointer;
|
||
transition: all 0.15s ease;
|
||
}
|
||
.refresh-btn:hover { border-color: var(--azure); color: var(--azure); }
|
||
.refresh-btn.spinning .icon { animation: spin 0.8s linear infinite; }
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
|
||
.diff-stat {
|
||
font-family: var(--mono);
|
||
font-size: 0.78rem;
|
||
color: var(--muted);
|
||
white-space: pre-wrap;
|
||
padding: 12px 16px;
|
||
background: #f1f5f9;
|
||
border-radius: var(--radius-compact);
|
||
margin-top: 8px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.diff-overlay {
|
||
align-items: stretch;
|
||
background: rgba(15, 23, 42, 0.35);
|
||
display: none;
|
||
inset: 0;
|
||
justify-content: flex-end;
|
||
position: fixed;
|
||
z-index: 20;
|
||
}
|
||
.diff-overlay.open { display: flex; }
|
||
.diff-panel {
|
||
background: var(--surface);
|
||
border-left: 1px solid var(--border);
|
||
box-shadow: -12px 0 32px rgba(15, 23, 42, 0.15);
|
||
display: flex;
|
||
flex-direction: column;
|
||
max-width: 760px;
|
||
width: min(78vw, 760px);
|
||
}
|
||
.diff-header {
|
||
align-items: center;
|
||
border-bottom: 1px solid var(--border);
|
||
display: flex;
|
||
gap: 12px;
|
||
padding: 14px 18px;
|
||
}
|
||
.diff-title {
|
||
flex: 1;
|
||
font-family: var(--mono);
|
||
font-size: 0.82rem;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.diff-close {
|
||
background: transparent;
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
color: var(--muted);
|
||
cursor: pointer;
|
||
font-size: 1rem;
|
||
height: 30px;
|
||
width: 30px;
|
||
}
|
||
.diff-content {
|
||
background: #0d1117;
|
||
color: #c9d1d9;
|
||
flex: 1;
|
||
font-family: var(--mono);
|
||
font-size: 0.75rem;
|
||
line-height: 1.55;
|
||
margin: 0;
|
||
overflow: auto;
|
||
padding: 18px;
|
||
tab-size: 4;
|
||
white-space: pre;
|
||
}
|
||
.diff-loading {
|
||
color: var(--muted);
|
||
padding: 24px;
|
||
}
|
||
|
||
.loading {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 4rem 0;
|
||
color: var(--meta);
|
||
font-size: 0.9rem;
|
||
gap: 0.5rem;
|
||
}
|
||
.loading .dot {
|
||
width: 6px; height: 6px;
|
||
border-radius: 50%;
|
||
background: var(--azure);
|
||
animation: pulse 1.2s ease-in-out infinite;
|
||
}
|
||
.loading .dot:nth-child(2) { animation-delay: 0.2s; }
|
||
.loading .dot:nth-child(3) { animation-delay: 0.4s; }
|
||
@keyframes pulse { 0%,100% { opacity: 0.3; } 50% { opacity: 1; } }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="app">
|
||
<div class="loading">
|
||
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
|
||
<span style="margin-left: 8px;">Reconstructing your context…</span>
|
||
</div>
|
||
</div>
|
||
<div id="diff-overlay" class="diff-overlay">
|
||
<section class="diff-panel" role="dialog" aria-modal="true" aria-labelledby="diff-title" tabindex="-1">
|
||
<div class="diff-header">
|
||
<span id="diff-status" class="status-badge modified">Changed</span>
|
||
<span id="diff-title" class="diff-title"></span>
|
||
<button id="diff-close" class="diff-close" aria-label="Close diff">×</button>
|
||
</div>
|
||
<pre id="diff-content" class="diff-content"></pre>
|
||
</section>
|
||
</div>
|
||
|
||
<script nonce="${scriptNonce}">
|
||
const instanceId = "${instanceId}";
|
||
let contextData = null;
|
||
let diffPreviousFocus = null;
|
||
let diffRequestController = null;
|
||
const capabilityToken = new URLSearchParams(window.location.search).get("k") || "";
|
||
|
||
function authorizedUrl(path) {
|
||
const url = new URL(path, window.location.origin);
|
||
url.searchParams.set("k", capabilityToken);
|
||
return url.pathname + url.search;
|
||
}
|
||
|
||
function timeAgo(isoString) {
|
||
if (!isoString) return "";
|
||
const diff = Date.now() - new Date(isoString).getTime();
|
||
const mins = Math.floor(diff / 60000);
|
||
if (mins < 1) return "just now";
|
||
if (mins < 60) return mins + "m ago";
|
||
const hrs = Math.floor(mins / 60);
|
||
const remainMins = mins % 60;
|
||
if (hrs < 24) return hrs + "h " + remainMins + "m ago";
|
||
const days = Math.floor(hrs / 24);
|
||
return days + "d " + (hrs % 24) + "h ago";
|
||
}
|
||
|
||
function timeAwayLabel(isoString) {
|
||
if (!isoString) return "";
|
||
const diff = Date.now() - new Date(isoString).getTime();
|
||
const mins = Math.floor(diff / 60000);
|
||
if (mins < 2) return "You\\'re still in the zone";
|
||
if (mins < 60) return "Away for " + mins + " minutes";
|
||
const hrs = Math.floor(mins / 60);
|
||
const remainMins = mins % 60;
|
||
if (hrs < 24) return "Away for " + hrs + "h " + remainMins + "m";
|
||
const days = Math.floor(hrs / 24);
|
||
return "Away for " + days + " day" + (days > 1 ? "s" : "") + " " + (hrs % 24) + "h";
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
return String(s || "").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'");
|
||
}
|
||
|
||
function describeStatus(code) {
|
||
if (code === "??") return { label: "Untracked", kind: "untracked" };
|
||
if (["DD", "AU", "UD", "UA", "DU", "AA", "UU"].includes(code)) {
|
||
return { label: "Unmerged", kind: "conflicted" };
|
||
}
|
||
const index = code[0];
|
||
const worktree = code[1];
|
||
if (index === "R") return { label: worktree === " " ? "Renamed" : "Renamed + edited", kind: "renamed" };
|
||
if (index === "A") return { label: worktree === " " ? "Staged add" : "Added + edited", kind: "added" };
|
||
if (index === "D" || worktree === "D") return { label: index !== " " && worktree !== " " ? "Staged + deleted" : "Deleted", kind: "deleted" };
|
||
if (index !== " " && worktree !== " ") return { label: "Staged + edited", kind: "modified" };
|
||
if (index !== " ") return { label: "Staged", kind: "modified" };
|
||
return { label: "Modified", kind: "modified" };
|
||
}
|
||
|
||
function renderGraphRows(rows, branchHashes) {
|
||
return rows.map(row => row.hash ? \`
|
||
<div class="graph-row \${branchHashes.has(row.hash) ? "worktree-commit" : ""}">
|
||
<span class="graph-lines">\${escapeHtml(row.graph || "* ")}</span>
|
||
<span class="graph-hash">\${escapeHtml(row.hash)}</span>
|
||
<span class="graph-subject" title="\${escapeHtml(row.subject)}">\${escapeHtml(row.subject)}</span>
|
||
<span class="graph-refs">
|
||
\${(row.refs || "").split(",").map(ref => ref.trim()).filter(Boolean).map(ref => \`<span class="graph-ref">\${escapeHtml(ref)}</span>\`).join("")}
|
||
</span>
|
||
</div>
|
||
\` : \`
|
||
<div class="graph-row">
|
||
<span class="graph-lines">\${escapeHtml(row.graph)}</span>
|
||
</div>
|
||
\`).join("");
|
||
}
|
||
|
||
function render(data) {
|
||
contextData = data;
|
||
const app = document.getElementById("app");
|
||
|
||
const worktreeCommits = data.branchCommits || [];
|
||
const commitSource = worktreeCommits.length ? worktreeCommits : (data.recentCommits || []);
|
||
const commits = commitSource.map(c => {
|
||
const parts = c.split(" ");
|
||
const hash = parts[0] || "";
|
||
const msg = parts.slice(1).join(" ");
|
||
return { hash, msg };
|
||
});
|
||
|
||
const files = data.changes || (data.uncommitted || []).map(f => ({
|
||
code: f.substring(0, 2),
|
||
path: f.substring(3)
|
||
}));
|
||
const branchHashes = new Set(worktreeCommits.map(commit => commit.split(" ")[0]));
|
||
const graph = data.commitGraph || [];
|
||
// The server computes the split so only the suffix after the last branch commit collapses.
|
||
const firstBaseCommit = Number.isInteger(data.baseGraphStart) ? data.baseGraphStart : -1;
|
||
const focusedGraph = firstBaseCommit >= 0 ? graph.slice(0, firstBaseCommit) : graph;
|
||
const baseGraph = firstBaseCommit >= 0 ? graph.slice(firstBaseCommit) : [];
|
||
const baseCommitCount = baseGraph.filter(row => row.hash).length;
|
||
|
||
const prs = data.openPrs || [];
|
||
const issues = data.assignedIssues || [];
|
||
const hasThreads = prs.length > 0 || issues.length > 0;
|
||
|
||
app.innerHTML = \`
|
||
<div class="header">
|
||
<h1>Where was I?</h1>
|
||
<div style="display:flex;align-items:center;gap:0.75rem;">
|
||
\${data.gatheredAt ? \`<span class="time-away">\${timeAwayLabel(data.gatheredAt)}</span>\` : ""}
|
||
<button id="refresh-context" class="refresh-btn" type="button">
|
||
<span class="icon">↻</span> Refresh
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="branch-bar">
|
||
<span class="icon">⎇</span>
|
||
<span class="label">Worktree</span>
|
||
<span class="worktree-name" title="\${escapeHtml(data.worktreeRoot)}">\${escapeHtml(data.worktreeName || data.worktreeRoot) || "unknown"}</span>
|
||
<span class="divider"></span>
|
||
<span class="label">Branch</span>
|
||
<span class="branch-name">\${escapeHtml(data.branch) || "detached HEAD"}</span>
|
||
\${data.baseRef ? \`<span class="divergence">\${data.ahead || 0} ahead · \${data.behind || 0} behind \${escapeHtml(data.baseRef)}</span>\` : ""}
|
||
</div>
|
||
|
||
\${(data.warnings || []).length ? \`
|
||
<div class="warnings">\${data.warnings.map(escapeHtml).join("<br>")}</div>
|
||
\` : ""}
|
||
|
||
\${graph.length ? \`
|
||
<div class="section">
|
||
<div class="section-title">Git graph</div>
|
||
<div class="card git-graph">
|
||
\${renderGraphRows(focusedGraph, branchHashes)}
|
||
\${baseGraph.length ? \`
|
||
<details class="graph-history">
|
||
<summary>Show \${baseCommitCount} earlier commit\${baseCommitCount === 1 ? "" : "s"} from \${escapeHtml(data.baseRef || "base branch")}</summary>
|
||
\${renderGraphRows(baseGraph, branchHashes)}
|
||
</details>
|
||
\` : ""}
|
||
</div>
|
||
</div>
|
||
\` : commits.length ? \`
|
||
<div class="section">
|
||
<div class="section-title">Recent commits</div>
|
||
<div class="card"><ul class="commit-list">
|
||
\${commits.map(c => \`<li><span class="commit-hash">\${escapeHtml(c.hash)}</span><span class="commit-msg">\${escapeHtml(c.msg)}</span></li>\`).join("")}
|
||
</ul></div>
|
||
</div>
|
||
\` : ""}
|
||
|
||
\${files.length ? \`
|
||
<div class="section">
|
||
<div class="section-title">Uncommitted Changes</div>
|
||
<div class="card">
|
||
<ul class="file-list">
|
||
\${files.map(f => {
|
||
const status = describeStatus(f.code);
|
||
return \`
|
||
<li>
|
||
<button type="button" class="file-change" data-path="\${escapeHtml(encodeURIComponent(f.path))}" data-code="\${escapeHtml(f.code)}">
|
||
<span class="status-badge \${status.kind}">\${status.label}</span>
|
||
<span class="file-path">\${escapeHtml(f.path)}</span>
|
||
<span class="view-diff">View diff</span>
|
||
</button>
|
||
</li>
|
||
\`;
|
||
}).join("")}
|
||
</ul>
|
||
\${data.diffStat ? \`<div class="diff-stat">\${escapeHtml(data.diffStat)}</div>\` : ""}
|
||
</div>
|
||
</div>
|
||
\` : ""}
|
||
|
||
\${hasThreads ? \`
|
||
<div class="section">
|
||
<div class="section-title">Open Threads</div>
|
||
<div class="thread-cards">
|
||
\${prs.map((pr, index) => \`
|
||
<button type="button" class="thread-card card-clickable" data-thread-kind="pr" data-thread-index="\${index}">
|
||
<span class="number">#\${pr.number}</span>
|
||
<span class="title">\${escapeHtml(pr.title)}</span>
|
||
<span class="badge badge-pr">PR</span>
|
||
</button>
|
||
\`).join("")}
|
||
\${issues.map((iss, index) => \`
|
||
<button type="button" class="thread-card card-clickable" data-thread-kind="issue" data-thread-index="\${index}">
|
||
<span class="number">#\${iss.number}</span>
|
||
<span class="title">\${escapeHtml(iss.title)}</span>
|
||
<span class="badge badge-issue">Issue</span>
|
||
</button>
|
||
\`).join("")}
|
||
</div>
|
||
</div>
|
||
\` : ""}
|
||
|
||
<div class="resume-section">
|
||
<button id="resume-context" class="resume-btn" type="button">
|
||
↩ Resume where I left off
|
||
</button>
|
||
<p class="resume-hint">Sends your full context to the agent so it can help you pick up</p>
|
||
</div>
|
||
\`;
|
||
wireFileChangeButtons();
|
||
wireContextButtons(prs, issues);
|
||
}
|
||
|
||
function wireFileChangeButtons() {
|
||
document.querySelectorAll(".file-change").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
showDiff(decodeURIComponent(button.dataset.path), button.dataset.code || "");
|
||
});
|
||
});
|
||
}
|
||
|
||
function wireContextButtons(prs, issues) {
|
||
document.getElementById("refresh-context").addEventListener("click", (event) => {
|
||
doRefresh(event.currentTarget);
|
||
});
|
||
document.getElementById("resume-context").addEventListener("click", () => {
|
||
doResume();
|
||
});
|
||
document.querySelectorAll(".thread-card").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
const items = button.dataset.threadKind === "pr" ? prs : issues;
|
||
const item = items[Number(button.dataset.threadIndex)];
|
||
if (!item) return;
|
||
const kind = button.dataset.threadKind === "pr" ? "PR" : "Issue";
|
||
resumeThread(kind + " #" + item.number + ": " + item.title);
|
||
});
|
||
});
|
||
}
|
||
|
||
async function doRefresh(btn) {
|
||
if (btn) btn.classList.add("spinning");
|
||
try {
|
||
const res = await fetch(authorizedUrl("/refresh"), { method: "POST" });
|
||
const data = await res.json();
|
||
if (!res.ok) throw new Error(data.error || "Unable to refresh context.");
|
||
render(data);
|
||
} catch (error) {
|
||
const app = document.getElementById("app");
|
||
app.insertAdjacentHTML("afterbegin", \`<div class="warnings">\${escapeHtml(error.message)}</div>\`);
|
||
}
|
||
if (btn) setTimeout(() => btn.classList.remove("spinning"), 300);
|
||
}
|
||
|
||
async function showDiff(path, code = "") {
|
||
if (diffRequestController) diffRequestController.abort();
|
||
const controller = new AbortController();
|
||
diffRequestController = controller;
|
||
const overlay = document.getElementById("diff-overlay");
|
||
const title = document.getElementById("diff-title");
|
||
const content = document.getElementById("diff-content");
|
||
const badge = document.getElementById("diff-status");
|
||
title.textContent = path;
|
||
badge.textContent = "Loading";
|
||
badge.className = "status-badge modified";
|
||
content.textContent = "Loading changes…";
|
||
diffPreviousFocus = document.activeElement;
|
||
overlay.classList.add("open");
|
||
document.getElementById("diff-close").focus();
|
||
try {
|
||
const res = await fetch(
|
||
authorizedUrl("/file-diff?path=" + encodeURIComponent(path) + (code ? "&code=" + encodeURIComponent(code) : "")),
|
||
{ signal: controller.signal }
|
||
);
|
||
const data = await res.json();
|
||
if (diffRequestController !== controller) return;
|
||
if (!res.ok) throw new Error(data.error || "Unable to load this diff.");
|
||
const status = describeStatus(data.code);
|
||
badge.textContent = status.label;
|
||
badge.className = "status-badge " + status.kind;
|
||
content.textContent = data.diff || "No textual diff is available.";
|
||
} catch (error) {
|
||
if (error.name === "AbortError" || diffRequestController !== controller) return;
|
||
badge.textContent = "Error";
|
||
content.textContent = error.message;
|
||
} finally {
|
||
if (diffRequestController === controller) diffRequestController = null;
|
||
}
|
||
}
|
||
|
||
function closeDiff() {
|
||
const overlay = document.getElementById("diff-overlay");
|
||
if (!overlay.classList.contains("open")) return;
|
||
if (diffRequestController) diffRequestController.abort();
|
||
diffRequestController = null;
|
||
overlay.classList.remove("open");
|
||
if (diffPreviousFocus && typeof diffPreviousFocus.focus === "function") {
|
||
diffPreviousFocus.focus();
|
||
}
|
||
diffPreviousFocus = null;
|
||
}
|
||
|
||
function handleDiffBackdrop(event) {
|
||
if (event.target.id === "diff-overlay") closeDiff();
|
||
}
|
||
|
||
function handleDiffKeydown(event) {
|
||
if (event.key === "Escape") {
|
||
event.preventDefault();
|
||
closeDiff();
|
||
return;
|
||
}
|
||
if (event.key !== "Tab") return;
|
||
|
||
const panel = document.querySelector(".diff-panel");
|
||
const focusable = Array.from(panel.querySelectorAll("button, [href], [tabindex]"))
|
||
.filter((element) => !element.disabled && element.tabIndex >= 0);
|
||
if (!focusable.length) {
|
||
event.preventDefault();
|
||
panel.focus();
|
||
return;
|
||
}
|
||
|
||
const first = focusable[0];
|
||
const last = focusable[focusable.length - 1];
|
||
if (event.shiftKey && (document.activeElement === first || !panel.contains(document.activeElement))) {
|
||
event.preventDefault();
|
||
last.focus();
|
||
} else if (!event.shiftKey && (document.activeElement === last || !panel.contains(document.activeElement))) {
|
||
event.preventDefault();
|
||
first.focus();
|
||
}
|
||
}
|
||
|
||
const diffOverlay = document.getElementById("diff-overlay");
|
||
diffOverlay.addEventListener("click", handleDiffBackdrop);
|
||
diffOverlay.addEventListener("keydown", handleDiffKeydown);
|
||
document.getElementById("diff-close").addEventListener("click", closeDiff);
|
||
|
||
function showWarning(message) {
|
||
const warning = document.createElement("div");
|
||
warning.className = "warnings";
|
||
warning.setAttribute("role", "alert");
|
||
warning.textContent = message;
|
||
document.getElementById("app").prepend(warning);
|
||
}
|
||
|
||
async function requestResume(thread) {
|
||
try {
|
||
const res = await fetch(authorizedUrl("/resume"), {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ thread })
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) throw new Error(data.error || "Unable to send the resume prompt.");
|
||
} catch (error) {
|
||
showWarning(error.message || "Unable to send the resume prompt.");
|
||
}
|
||
}
|
||
|
||
async function doResume() {
|
||
await requestResume(null);
|
||
}
|
||
|
||
async function resumeThread(thread) {
|
||
await requestResume(thread);
|
||
}
|
||
|
||
// SSE for live updates
|
||
const evtSource = new EventSource(authorizedUrl("/events"));
|
||
evtSource.onmessage = (e) => {
|
||
try {
|
||
const data = JSON.parse(e.data);
|
||
render(data);
|
||
} catch {}
|
||
};
|
||
|
||
// Initial load
|
||
fetch(authorizedUrl("/context"))
|
||
.then(r => r.json())
|
||
.then(render)
|
||
.catch((error) => {
|
||
document.getElementById("app").innerHTML = \`<div class="warnings">\${escapeHtml(error.message)}</div>\`;
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>`;
|
||
}
|
||
|
||
// --- Server ---
|
||
|
||
function tokenMatches(provided, expected) {
|
||
if (typeof provided !== "string" || provided.length !== expected.length) return false;
|
||
try {
|
||
return timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function requestIsAuthorized(req, url, entry) {
|
||
const host = String(req.headers.host || "").toLowerCase();
|
||
if (!entry.canonicalHost || host !== entry.canonicalHost) return false;
|
||
const origin = req.headers.origin;
|
||
if (origin && origin !== entry.origin) return false;
|
||
return tokenMatches(url.searchParams.get("k") || "", entry.token);
|
||
}
|
||
|
||
async function startServer(instanceId, cwd, workspacePath) {
|
||
const entry = {
|
||
server: null,
|
||
url: "",
|
||
cwd,
|
||
token: randomBytes(32).toString("base64url"),
|
||
canonicalHost: "",
|
||
origin: "",
|
||
};
|
||
entry.server = createServer(async (req, res) => {
|
||
const url = new URL(req.url, "http://localhost");
|
||
if (!requestIsAuthorized(req, url, entry)) {
|
||
res.writeHead(403, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ error: "Forbidden" }));
|
||
return;
|
||
}
|
||
|
||
if (url.pathname === "/events" && req.method === "GET") {
|
||
res.writeHead(200, {
|
||
"Content-Type": "text/event-stream",
|
||
"Cache-Control": "no-cache",
|
||
"Connection": "keep-alive",
|
||
});
|
||
res.write(":\n\n");
|
||
let clients = sseClients.get(instanceId);
|
||
if (!clients) { clients = new Set(); sseClients.set(instanceId, clients); }
|
||
clients.add(res);
|
||
req.on("close", () => { clients.delete(res); });
|
||
return;
|
||
}
|
||
|
||
if (url.pathname === "/context" && req.method === "GET") {
|
||
const data = contextCache.get(instanceId) || {};
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify(data));
|
||
return;
|
||
}
|
||
|
||
if (url.pathname === "/file-diff" && req.method === "GET") {
|
||
const path = url.searchParams.get("path");
|
||
const code = url.searchParams.get("code");
|
||
if (!path) {
|
||
res.writeHead(400, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ error: "A file path is required." }));
|
||
return;
|
||
}
|
||
try {
|
||
const data = await getFileDiff(entry.cwd, path, code && code.length === 2 ? code : null);
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify(data));
|
||
} catch (error) {
|
||
res.writeHead(400, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ error: error.message || "Unable to load this diff." }));
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (url.pathname === "/refresh" && req.method === "POST") {
|
||
try {
|
||
const data = await gatherContext(entry.cwd);
|
||
contextCache.set(instanceId, data);
|
||
await saveContext(workspacePath, data);
|
||
broadcast(instanceId, data);
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify(data));
|
||
} catch (error) {
|
||
res.writeHead(500, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ error: error.message || "Unable to refresh context." }));
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (url.pathname === "/resume" && req.method === "POST") {
|
||
let body = "";
|
||
for await (const chunk of req) body += chunk;
|
||
let thread = null;
|
||
try {
|
||
thread = JSON.parse(body).thread;
|
||
} catch {
|
||
res.writeHead(400, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ error: "The resume request body must be valid JSON." }));
|
||
return;
|
||
}
|
||
|
||
const ctx = contextCache.get(instanceId) || {};
|
||
const commits = ctx.branchCommits?.length ? ctx.branchCommits : (ctx.recentCommits || []);
|
||
let prompt;
|
||
if (thread) {
|
||
prompt = `I was working on ${thread} and got interrupted. Here's my current context:\n\n` +
|
||
`**Worktree:** ${ctx.worktreeRoot || "unknown"}\n` +
|
||
`**Branch:** ${ctx.branch || "unknown"}\n` +
|
||
`**Worktree commits:** ${commits.join(", ")}\n` +
|
||
`**Uncommitted changes:** ${(ctx.uncommitted || []).join(", ")}\n` +
|
||
`**Open PRs:** ${(ctx.openPrs || []).map(p => "#" + p.number + " " + p.title).join(", ")}\n\n` +
|
||
`Help me pick up where I left off on this specific thread.`;
|
||
} else {
|
||
prompt = `I got interrupted and need to resume my work. Here's my full context:\n\n` +
|
||
`**Worktree:** ${ctx.worktreeRoot || "unknown"}\n` +
|
||
`**Branch:** ${ctx.branch || "unknown"}\n` +
|
||
`**Worktree commits:**\n${commits.map(c => "- " + c).join("\n")}\n\n` +
|
||
`**Uncommitted changes:**\n${(ctx.uncommitted || []).map(f => "- " + f).join("\n")}\n\n` +
|
||
`**Diff stat:**\n${ctx.diffStat || "none"}\n\n` +
|
||
`**Open PRs:** ${(ctx.openPrs || []).map(p => "#" + p.number + " " + p.title).join(", ") || "none"}\n` +
|
||
`**Assigned issues:** ${(ctx.assignedIssues || []).map(i => "#" + i.number + " " + i.title).join(", ") || "none"}\n\n` +
|
||
`Help me pick up where I left off. What should I focus on first?`;
|
||
}
|
||
|
||
try {
|
||
if (!sessionRef) throw new Error("The Copilot session is unavailable.");
|
||
await sessionRef.send(prompt);
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ ok: true }));
|
||
} catch (error) {
|
||
res.writeHead(500, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ error: error.message || "Unable to send the resume prompt." }));
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (url.pathname === "/" && req.method === "GET") {
|
||
const scriptNonce = randomBytes(16).toString("base64url");
|
||
res.writeHead(200, {
|
||
"Content-Type": "text/html; charset=utf-8",
|
||
"Content-Security-Policy": [
|
||
"default-src 'none'",
|
||
`script-src 'nonce-${scriptNonce}'`,
|
||
"style-src 'unsafe-inline' https://fonts.googleapis.com",
|
||
"font-src https://fonts.gstatic.com",
|
||
"connect-src 'self'",
|
||
"base-uri 'none'",
|
||
"object-src 'none'",
|
||
].join("; "),
|
||
});
|
||
res.end(renderHtml(instanceId, scriptNonce));
|
||
return;
|
||
}
|
||
|
||
res.writeHead(404, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ error: "Not found" }));
|
||
});
|
||
|
||
await new Promise((resolve) => entry.server.listen(0, "127.0.0.1", resolve));
|
||
const address = entry.server.address();
|
||
const port = typeof address === "object" && address ? address.port : 0;
|
||
entry.canonicalHost = `127.0.0.1:${port}`;
|
||
entry.origin = `http://${entry.canonicalHost}`;
|
||
entry.url = `${entry.origin}/?k=${entry.token}`;
|
||
return entry;
|
||
}
|
||
|
||
// --- Extension ---
|
||
|
||
let sessionRef = null;
|
||
|
||
const session = await joinSession({
|
||
canvases: [
|
||
createCanvas({
|
||
id: "where-was-i",
|
||
displayName: "Where Was I?",
|
||
description: "Reconstruct your dev context (branch, commits, uncommitted work, PR clues) and trigger a resume prompt to continue quickly.",
|
||
actions: [
|
||
{
|
||
name: "refresh",
|
||
description: "Re-gather all git/project context and push updates to the canvas",
|
||
handler: async (ctx) => {
|
||
const cwd = await activeCwd(ctx);
|
||
const entry = servers.get(ctx.instanceId);
|
||
if (entry) entry.cwd = cwd;
|
||
const data = await gatherContext(cwd);
|
||
contextCache.set(ctx.instanceId, data);
|
||
if (sessionRef) await saveContext(sessionRef.workspacePath, data);
|
||
broadcast(ctx.instanceId, data);
|
||
return data;
|
||
},
|
||
},
|
||
{
|
||
name: "get_context",
|
||
description: "Return the currently assembled developer context as JSON",
|
||
handler: async (ctx) => {
|
||
return contextCache.get(ctx.instanceId) || {};
|
||
},
|
||
},
|
||
{
|
||
name: "get_file_diff",
|
||
description: "Return the staged, unstaged, or untracked patch for a changed file",
|
||
inputSchema: {
|
||
type: "object",
|
||
properties: {
|
||
path: {
|
||
type: "string",
|
||
minLength: 1,
|
||
description: "Repository-relative path from the current context",
|
||
},
|
||
code: {
|
||
type: "string",
|
||
minLength: 2,
|
||
maxLength: 2,
|
||
description: "Optional two-character porcelain status code to disambiguate a path listed more than once",
|
||
},
|
||
},
|
||
required: ["path"],
|
||
additionalProperties: false,
|
||
},
|
||
handler: async (ctx) => {
|
||
const cwd = await activeCwd(ctx);
|
||
return await getFileDiff(cwd, ctx.input.path, ctx.input.code || null);
|
||
},
|
||
},
|
||
{
|
||
name: "resume",
|
||
description: "Send a contextual 'resume' message to the agent with the developer's assembled state",
|
||
inputSchema: {
|
||
type: "object",
|
||
properties: {
|
||
thread: {
|
||
type: "string",
|
||
description: "Optional specific thread/topic to focus on when resuming",
|
||
},
|
||
},
|
||
},
|
||
handler: async (ctx) => {
|
||
const thread = ctx.input?.thread || null;
|
||
const data = contextCache.get(ctx.instanceId) || {};
|
||
const commits = data.branchCommits?.length
|
||
? data.branchCommits
|
||
: (data.recentCommits || []);
|
||
let prompt;
|
||
if (thread) {
|
||
prompt = `I was working on ${thread} and got interrupted. Context: worktree=${data.worktreeRoot}, branch=${data.branch}, worktree commits: ${commits.join("; ")}. Help me resume.`;
|
||
} else {
|
||
prompt = `Help me resume. Worktree: ${data.worktreeRoot}. Branch: ${data.branch}. Commits: ${commits.join("; ")}. Uncommitted: ${(data.uncommitted || []).join("; ")}.`;
|
||
}
|
||
if (sessionRef) await sessionRef.send(prompt);
|
||
return { sent: true };
|
||
},
|
||
},
|
||
],
|
||
open: async (ctx) => {
|
||
const cwd = await activeCwd(ctx);
|
||
let entry = servers.get(ctx.instanceId);
|
||
if (!entry) {
|
||
entry = await startServer(ctx.instanceId, cwd, sessionRef?.workspacePath);
|
||
servers.set(ctx.instanceId, entry);
|
||
} else {
|
||
entry.cwd = cwd;
|
||
}
|
||
|
||
const data = await gatherContext(cwd);
|
||
await saveContext(sessionRef?.workspacePath, data);
|
||
contextCache.set(ctx.instanceId, data);
|
||
// Push to any waiting SSE clients
|
||
setTimeout(() => broadcast(ctx.instanceId, data), 100);
|
||
|
||
return { title: "Where Was I?", url: entry.url };
|
||
},
|
||
onClose: async (ctx) => {
|
||
const entry = servers.get(ctx.instanceId);
|
||
if (entry) {
|
||
servers.delete(ctx.instanceId);
|
||
await new Promise((r) => entry.server.close(() => r()));
|
||
}
|
||
sseClients.delete(ctx.instanceId);
|
||
contextCache.delete(ctx.instanceId);
|
||
},
|
||
}),
|
||
],
|
||
});
|
||
|
||
sessionRef = session;
|