From 35838b19cc5d644eac826407e45b2e3044258478 Mon Sep 17 00:00:00 2001 From: James Montemagno Date: Fri, 17 Jul 2026 08:24:48 -0700 Subject: [PATCH 01/10] Add worktree context collector Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2d224e01-e53f-46f8-beb2-0b8b64c5ee89 --- extensions/where-was-i/git-context.mjs | 97 +++++++++++++++++++++ extensions/where-was-i/git-context.test.mjs | 64 ++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 extensions/where-was-i/git-context.mjs create mode 100644 extensions/where-was-i/git-context.test.mjs diff --git a/extensions/where-was-i/git-context.mjs b/extensions/where-was-i/git-context.mjs new file mode 100644 index 000000000..2b16162f4 --- /dev/null +++ b/extensions/where-was-i/git-context.mjs @@ -0,0 +1,97 @@ +import { execFile } from "node:child_process"; +import { basename } from "node:path"; + +function runGit(cwd, args, { optional = false } = {}) { + return new Promise((resolve, reject) => { + execFile( + "git", + args, + { cwd, timeout: 15000, maxBuffer: 1024 * 1024, encoding: "utf8" }, + (error, stdout, stderr) => { + if (error) { + if (optional) { + resolve(""); + return; + } + reject(new Error((stderr || error.message || "Git command failed").trim())); + return; + } + resolve((stdout || "").trim()); + }, + ); + }); +} + +async function resolveBaseRef(cwd, branch) { + const remoteDefault = await runGit( + cwd, + ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"], + { optional: true }, + ); + const candidates = [remoteDefault, "origin/main", "origin/master", "main", "master"] + .filter(Boolean) + .filter((ref, index, refs) => refs.indexOf(ref) === index && ref !== branch); + + for (const ref of candidates) { + const commit = await runGit(cwd, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], { + optional: true, + }); + if (commit) return ref; + } + return null; +} + +function lines(value) { + return value.split("\n").map((line) => line.trimEnd()).filter(Boolean); +} + +export async function gatherGitContext(cwd) { + const worktreeRoot = await runGit(cwd, ["rev-parse", "--show-toplevel"]); + const [branch, head] = await Promise.all([ + runGit(worktreeRoot, ["branch", "--show-current"]), + runGit(worktreeRoot, ["rev-parse", "--short", "HEAD"]), + ]); + const baseRef = await resolveBaseRef(worktreeRoot, branch); + const mergeBase = baseRef + ? await runGit(worktreeRoot, ["merge-base", "HEAD", baseRef], { optional: true }) + : ""; + + const branchRange = mergeBase ? `${mergeBase}..HEAD` : null; + const [branchLog, recentLog, status, diffStat, stagedDiffStat, unstagedDiffStat, divergence] = + await Promise.all([ + branchRange + ? runGit(worktreeRoot, ["log", "--format=%h %s", branchRange]) + : Promise.resolve(""), + runGit(worktreeRoot, ["log", "-10", "--format=%h %s", "HEAD"]), + runGit(worktreeRoot, ["status", "--short", "--untracked-files=all"]), + runGit(worktreeRoot, ["diff", "--stat", "HEAD"]), + runGit(worktreeRoot, ["diff", "--cached", "--stat"]), + runGit(worktreeRoot, ["diff", "--stat"]), + baseRef + ? runGit(worktreeRoot, ["rev-list", "--left-right", "--count", `${baseRef}...HEAD`], { + optional: true, + }) + : Promise.resolve(""), + ]); + + const [behind = 0, ahead = 0] = divergence + .split(/\s+/) + .filter(Boolean) + .map((value) => Number.parseInt(value, 10) || 0); + + return { + worktreeRoot, + worktreeName: basename(worktreeRoot), + branch, + head, + baseRef, + ahead, + behind, + branchCommits: lines(branchLog), + recentCommits: lines(recentLog), + uncommitted: lines(status), + diffStat, + stagedDiffStat, + unstagedDiffStat, + }; +} diff --git a/extensions/where-was-i/git-context.test.mjs b/extensions/where-was-i/git-context.test.mjs new file mode 100644 index 000000000..393f53e7e --- /dev/null +++ b/extensions/where-was-i/git-context.test.mjs @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { gatherGitContext } from "./git-context.mjs"; + +function git(cwd, ...args) { + return execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); +} + +function write(cwd, path, content) { + writeFileSync(join(cwd, path), content, "utf8"); +} + +test("gathers branch commits and every worktree change", async (t) => { + const cwd = mkdtempSync(join(tmpdir(), "where-was-i-")); + t.after(() => rmSync(cwd, { recursive: true, force: true })); + + git(cwd, "init", "-b", "main"); + git(cwd, "config", "user.name", "Canvas Tester"); + git(cwd, "config", "user.email", "canvas@example.com"); + write(cwd, "staged.txt", "initial\n"); + write(cwd, "unstaged.txt", "initial\n"); + git(cwd, "add", "."); + git(cwd, "commit", "-m", "Seed repository"); + + git(cwd, "switch", "-c", "feature/context"); + write(cwd, "first.txt", "first\n"); + git(cwd, "add", "first.txt"); + git(cwd, "commit", "-m", "Add first feature commit"); + git(cwd, "config", "user.name", "Another Contributor"); + git(cwd, "config", "user.email", "another@example.com"); + write(cwd, "second.txt", "second\n"); + git(cwd, "add", "second.txt"); + git(cwd, "commit", "-m", "Add second feature commit"); + + write(cwd, "staged.txt", "staged change\n"); + git(cwd, "add", "staged.txt"); + write(cwd, "unstaged.txt", "unstaged change\n"); + write(cwd, "untracked.txt", "untracked change\n"); + + const context = await gatherGitContext(cwd); + + assert.equal(context.worktreeRoot.replaceAll("\\", "/"), cwd.replaceAll("\\", "/")); + assert.equal(context.worktreeName, cwd.split(/[\\/]/).at(-1)); + assert.equal(context.branch, "feature/context"); + assert.equal(context.baseRef, "main"); + assert.equal(context.ahead, 2); + assert.equal(context.behind, 0); + assert.deepEqual( + context.branchCommits.map((commit) => commit.replace(/^[0-9a-f]+ /, "")), + ["Add second feature commit", "Add first feature commit"], + ); + assert.match(context.uncommitted.join("\n"), /M staged\.txt/); + assert.match(context.uncommitted.join("\n"), / M unstaged\.txt/); + assert.match(context.uncommitted.join("\n"), /\?\? untracked\.txt/); + assert.match(context.diffStat, /staged\.txt/); + assert.match(context.diffStat, /unstaged\.txt/); + assert.match(context.stagedDiffStat, /staged\.txt/); + assert.match(context.unstagedDiffStat, /unstaged\.txt/); +}); From 49443d825de6c38a3ae63bf69cae9ada851968d7 Mon Sep 17 00:00:00 2001 From: James Montemagno Date: Fri, 17 Jul 2026 08:31:37 -0700 Subject: [PATCH 02/10] Fix Where Was I worktree state Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2d224e01-e53f-46f8-beb2-0b8b64c5ee89 --- .github/plugin/marketplace.json | 2 +- .../where-was-i/.github/plugin/plugin.json | 2 +- extensions/where-was-i/extension.mjs | 260 +++++++++++------- extensions/where-was-i/package.json | 2 +- 4 files changed, 164 insertions(+), 102 deletions(-) diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index 37acb1e95..e15fbf02a 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -1335,7 +1335,7 @@ "name": "where-was-i", "source": "extensions/where-was-i", "description": "Reconstruct your dev context (branch, commits, uncommitted work, PR clues) and trigger a resume prompt to continue quickly.", - "version": "1.0.1" + "version": "1.0.2" }, { "name": "winappcli", diff --git a/extensions/where-was-i/.github/plugin/plugin.json b/extensions/where-was-i/.github/plugin/plugin.json index e43c6f26d..7ef8a6796 100644 --- a/extensions/where-was-i/.github/plugin/plugin.json +++ b/extensions/where-was-i/.github/plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "where-was-i", "description": "Reconstruct your dev context (branch, commits, uncommitted work, PR clues) and trigger a resume prompt to continue quickly.", - "version": "1.0.1", + "version": "1.0.2", "author": { "name": "Aaron Powell", "url": "https://github.com/aaronpowell" diff --git a/extensions/where-was-i/extension.mjs b/extensions/where-was-i/extension.mjs index c376fa9b4..ce4f160f9 100644 --- a/extensions/where-was-i/extension.mjs +++ b/extensions/where-was-i/extension.mjs @@ -3,28 +3,15 @@ import { createServer } from "node:http"; import { execFile } from "node:child_process"; -import { readFile, writeFile, mkdir } from "node:fs/promises"; -import { join, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; -import { joinSession, createCanvas } from "@github/copilot-sdk/extension"; +import { writeFile, mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { joinSession, createCanvas, CanvasError } from "@github/copilot-sdk/extension"; +import { gatherGitContext } from "./git-context.mjs"; const servers = new Map(); const sseClients = new Map(); // instanceId → Set const contextCache = new Map(); // instanceId → contextData -const isWindows = process.platform === "win32"; - -// Fallback repo root derived from extension location. Only used when the -// session's real working directory is unavailable (see captureCwd below). -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const REPO_ROOT = join(__dirname, "..", "..", ".."); - -// The canvas request context reports the active session's working directory — -// the actual repo checkout or worktree the user opened the canvas in. This is -// what git commands must run against; REPO_ROOT (the extension's install dir) -// and session.workspacePath (the session-state folder) are NOT the repo, which -// is why the board previously showed an empty branch as "detached HEAD". let workspaceCwd = null; function captureCwd(ctx) { @@ -32,52 +19,61 @@ function captureCwd(ctx) { if (typeof dir === "string" && dir.trim()) workspaceCwd = dir; } -function repoCwd() { - return workspaceCwd || REPO_ROOT; +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; } -// --- Shell helpers --- - -function run(cmd, cwd) { - const shell = isWindows ? "powershell" : "bash"; - const args = isWindows - ? ["-NoProfile", "-NoLogo", "-Command", cmd] - : ["-c", cmd]; - return new Promise((resolve) => { - execFile(shell, args, { cwd, timeout: 15000, maxBuffer: 1024 * 256 }, (err, stdout) => { - resolve(err ? "" : (stdout || "").trim()); +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) { - cwd = cwd || repoCwd(); - const authorCmd = isWindows - ? 'git log --oneline -5 --format="%h %s" --author="$(git config user.name)"' - : 'git log --oneline -5 --format="%h %s" --author="$(git config user.name)"'; - const suppressErr = isWindows ? "2>$null" : "2>/dev/null"; - - const [branch, log, status, diff, prs, issues] = await Promise.all([ - run("git branch --show-current", cwd), - run(authorCmd, cwd), - run("git status --short", cwd), - run("git diff --stat", cwd), - run(`gh pr list --author=@me --state=open --limit=10 --json number,title,url,updatedAt,comments ${suppressErr}`, cwd), - run(`gh issue list --assignee=@me --state=open --limit=10 --json number,title,url,updatedAt ${suppressErr}`, 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", + ]), ]); - let parsedPrs = []; - let parsedIssues = []; - try { parsedPrs = JSON.parse(prs || "[]"); } catch {} - try { parsedIssues = JSON.parse(issues || "[]"); } catch {} - return { - branch, - recentCommits: log.split("\n").filter(Boolean), - uncommitted: status.split("\n").filter(Boolean), - diffStat: diff, - openPrs: parsedPrs, - assignedIssues: parsedIssues, + ...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(), }; } @@ -87,18 +83,10 @@ async function gatherContext(cwd) { async function saveContext(workspacePath, data) { if (!workspacePath) return; const dir = join(workspacePath, "files"); - try { await mkdir(dir, { recursive: true }); } catch {} + await mkdir(dir, { recursive: true }); await writeFile(join(dir, "where-was-i-context.json"), JSON.stringify(data, null, 2)); } -async function loadContext(workspacePath) { - if (!workspacePath) return null; - try { - const raw = await readFile(join(workspacePath, "files", "where-was-i-context.json"), "utf-8"); - return JSON.parse(raw); - } catch { return null; } -} - // --- SSE --- function broadcast(instanceId, data) { @@ -106,7 +94,11 @@ function broadcast(instanceId, data) { if (!clients) return; const payload = `data: ${JSON.stringify(data)}\n\n`; for (const res of clients) { - try { res.write(payload); } catch {} + try { + res.write(payload); + } catch { + clients.delete(res); + } } } @@ -200,6 +192,25 @@ body { padding: 2rem 1.5rem 3rem; max-width: 880px; margin: 0 auto; } 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; @@ -357,6 +368,16 @@ body { padding: 2rem 1.5rem 3rem; max-width: 880px; margin: 0 auto; } 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; @@ -454,7 +475,9 @@ function render(data) { contextData = data; const app = document.getElementById("app"); - const commits = (data.recentCommits || []).map(c => { + 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(" "); @@ -484,13 +507,21 @@ function render(data) {
+ Worktree + \${escapeHtml(data.worktreeName || data.worktreeRoot) || "unknown"} + Branch \${escapeHtml(data.branch) || "detached HEAD"} + \${data.baseRef ? \`\${data.ahead || 0} ahead · \${data.behind || 0} behind \${escapeHtml(data.baseRef)}\` : ""}
+ \${(data.warnings || []).length ? \` +
\${data.warnings.map(escapeHtml).join("
")}
+ \` : ""} + \${commits.length ? \`
-
Recent Commits
+
\${worktreeCommits.length ? "Commits on this worktree" : "Recent commits"}
    \${commits.map(c => \` @@ -557,8 +588,12 @@ async function doRefresh(btn) { try { const res = await fetch("/refresh", { method: "POST" }); const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Unable to refresh context."); render(data); - } catch (e) {} + } catch (error) { + const app = document.getElementById("app"); + app.insertAdjacentHTML("afterbegin", \`
    \${escapeHtml(error.message)}
    \`); + } if (btn) setTimeout(() => btn.classList.remove("spinning"), 300); } @@ -588,7 +623,12 @@ evtSource.onmessage = (e) => { }; // Initial load -fetch("/context").then(r => r.json()).then(render).catch(() => {}); +fetch("/context") + .then(r => r.json()) + .then(render) + .catch((error) => { + document.getElementById("app").innerHTML = \`
    \${escapeHtml(error.message)}
    \`; + }); `; @@ -596,8 +636,9 @@ fetch("/context").then(r => r.json()).then(render).catch(() => {}); // --- Server --- -async function startServer(instanceId, sessionRef, cwd, workspacePath) { - const server = createServer(async (req, res) => { +async function startServer(instanceId, cwd, workspacePath) { + const entry = { server: null, url: "", cwd }; + entry.server = createServer(async (req, res) => { const url = new URL(req.url, "http://localhost"); if (url.pathname === "/events") { @@ -622,12 +663,17 @@ async function startServer(instanceId, sessionRef, cwd, workspacePath) { } if (url.pathname === "/refresh" && req.method === "POST") { - const data = await gatherContext(cwd); - contextCache.set(instanceId, data); - await saveContext(workspacePath, data); - broadcast(instanceId, data); - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify(data)); + 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; } @@ -635,21 +681,30 @@ async function startServer(instanceId, sessionRef, cwd, workspacePath) { let body = ""; for await (const chunk of req) body += chunk; let thread = null; - try { thread = JSON.parse(body).thread; } catch {} + 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` + - `**Recent commits:** ${(ctx.recentCommits || []).join(", ")}\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` + - `**Recent commits:**\n${(ctx.recentCommits || []).map(c => "- " + c).join("\n")}\n\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` + @@ -657,9 +712,15 @@ async function startServer(instanceId, sessionRef, cwd, workspacePath) { `Help me pick up where I left off. What should I focus on first?`; } - try { await sessionRef.send(prompt); } catch {} - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ ok: true })); + 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; } @@ -668,10 +729,11 @@ async function startServer(instanceId, sessionRef, cwd, workspacePath) { res.end(renderHtml(instanceId)); }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - const address = server.address(); + 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; - return { server, url: `http://127.0.0.1:${port}/` }; + entry.url = `http://127.0.0.1:${port}/`; + return entry; } // --- Extension --- @@ -689,8 +751,10 @@ const session = await joinSession({ name: "refresh", description: "Re-gather all git/project context and push updates to the canvas", handler: async (ctx) => { - captureCwd(ctx); - const data = await gatherContext(repoCwd()); + 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); @@ -719,11 +783,14 @@ const session = await joinSession({ 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: branch=${data.branch}, recent commits: ${(data.recentCommits || []).join("; ")}. Help me resume.`; + 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. Branch: ${data.branch}. Commits: ${(data.recentCommits || []).join("; ")}. Uncommitted: ${(data.uncommitted || []).join("; ")}.`; + 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 }; @@ -731,22 +798,17 @@ const session = await joinSession({ }, ], open: async (ctx) => { - captureCwd(ctx); + const cwd = await activeCwd(ctx); let entry = servers.get(ctx.instanceId); if (!entry) { - entry = await startServer(ctx.instanceId, sessionRef, repoCwd(), sessionRef?.workspacePath); + entry = await startServer(ctx.instanceId, cwd, sessionRef?.workspacePath); servers.set(ctx.instanceId, entry); + } else { + entry.cwd = cwd; } - // Load persisted context or gather fresh. Re-gather when the - // saved context is missing or has no branch (e.g. it was saved - // before the working directory was known), so the board never - // opens stuck on a stale "detached HEAD". - let data = await loadContext(sessionRef?.workspacePath); - if (!data || !data.branch) { - data = await gatherContext(repoCwd()); - await saveContext(sessionRef?.workspacePath, data); - } + 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); diff --git a/extensions/where-was-i/package.json b/extensions/where-was-i/package.json index 5534b4dbb..dca089c2f 100644 --- a/extensions/where-was-i/package.json +++ b/extensions/where-was-i/package.json @@ -1,6 +1,6 @@ { "name": "where-was-i", - "version": "1.0.0", + "version": "1.0.2", "type": "module", "main": "extension.mjs", "description": "Reconstruct your dev context (branch, commits, uncommitted work, PR clues) and trigger a resume prompt to continue quickly.", From 18619ead1baff1f170d0361f3b7674d08cf687f4 Mon Sep 17 00:00:00 2001 From: James Montemagno Date: Fri, 17 Jul 2026 08:42:25 -0700 Subject: [PATCH 03/10] Collect git graph and change details Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2d224e01-e53f-46f8-beb2-0b8b64c5ee89 --- extensions/where-was-i/git-context.mjs | 107 +++++++++++++++++++- extensions/where-was-i/git-context.test.mjs | 25 ++++- 2 files changed, 128 insertions(+), 4 deletions(-) diff --git a/extensions/where-was-i/git-context.mjs b/extensions/where-was-i/git-context.mjs index 2b16162f4..6cdc73934 100644 --- a/extensions/where-was-i/git-context.mjs +++ b/extensions/where-was-i/git-context.mjs @@ -1,5 +1,6 @@ import { execFile } from "node:child_process"; -import { basename } from "node:path"; +import { readFile, stat } from "node:fs/promises"; +import { basename, isAbsolute, relative, resolve } from "node:path"; function runGit(cwd, args, { optional = false } = {}) { return new Promise((resolve, reject) => { @@ -16,7 +17,7 @@ function runGit(cwd, args, { optional = false } = {}) { reject(new Error((stderr || error.message || "Git command failed").trim())); return; } - resolve((stdout || "").trim()); + resolve((stdout || "").trimEnd()); }, ); }); @@ -45,6 +46,93 @@ function lines(value) { return value.split("\n").map((line) => line.trimEnd()).filter(Boolean); } +export function parseStatusEntry(line) { + const code = line.slice(0, 2); + const rawPath = line.slice(3); + const renameMarker = rawPath.lastIndexOf(" -> "); + return { + code, + path: renameMarker >= 0 ? rawPath.slice(renameMarker + 4) : rawPath, + originalPath: renameMarker >= 0 ? rawPath.slice(0, renameMarker) : null, + }; +} + +function parseGraphLine(line) { + const [graphAndHash, subject = "", refs = ""] = line.split("\t"); + const hashMatch = graphAndHash.match(/([0-9a-f]{7,})$/); + return { + graph: hashMatch ? graphAndHash.slice(0, hashMatch.index) : graphAndHash, + hash: hashMatch?.[1] || "", + subject, + refs, + }; +} + +function assertRepositoryPath(root, path) { + const absolutePath = resolve(root, path); + const relativePath = relative(root, absolutePath); + if (!relativePath || relativePath.startsWith("..") || isAbsolute(relativePath)) { + throw new Error("The requested file must be inside the current worktree."); + } + return { absolutePath, relativePath }; +} + +async function renderUntrackedFile(root, path) { + const { absolutePath, relativePath } = assertRepositoryPath(root, path); + const fileStat = await stat(absolutePath); + if (!fileStat.isFile()) throw new Error("Only untracked files can be previewed."); + if (fileStat.size > 512 * 1024) throw new Error("This untracked file is too large to preview."); + + const content = await readFile(absolutePath); + if (content.includes(0)) return `Binary file ${relativePath} is untracked.`; + + const text = content.toString("utf8"); + const addedLines = text.split(/\r?\n/); + if (addedLines.at(-1) === "") addedLines.pop(); + return [ + `diff --git a/${relativePath} b/${relativePath}`, + "new file mode 100644", + "--- /dev/null", + `+++ b/${relativePath}`, + `@@ -0,0 +1,${addedLines.length} @@`, + ...addedLines.map((line) => `+${line}`), + ].join("\n"); +} + +export async function getFileDiff(cwd, requestedPath) { + const root = await runGit(cwd, ["rev-parse", "--show-toplevel"]); + const { relativePath } = assertRepositoryPath(root, requestedPath); + const status = await runGit(root, [ + "status", "--short", "--untracked-files=all", "--", relativePath, + ]); + const entry = lines(status).map(parseStatusEntry).find((item) => item.path === relativePath); + if (!entry) throw new Error("This file no longer has uncommitted changes."); + + if (entry.code === "??") { + return { + ...entry, + diff: await renderUntrackedFile(root, relativePath), + }; + } + + const patches = []; + if (entry.code[0] && entry.code[0] !== " ") { + const staged = await runGit(root, ["diff", "--cached", "--no-ext-diff", "--", relativePath]); + if (staged) patches.push({ kind: "Staged", content: staged }); + } + if (entry.code[1] && entry.code[1] !== " ") { + const unstaged = await runGit(root, ["diff", "--no-ext-diff", "--", relativePath]); + if (unstaged) patches.push({ kind: "Unstaged", content: unstaged }); + } + + return { + ...entry, + diff: patches + .map((patch) => patches.length > 1 ? `# ${patch.kind}\n${patch.content}` : patch.content) + .join("\n\n"), + }; +} + export async function gatherGitContext(cwd) { const worktreeRoot = await runGit(cwd, ["rev-parse", "--show-toplevel"]); const [branch, head] = await Promise.all([ @@ -57,12 +145,23 @@ export async function gatherGitContext(cwd) { : ""; const branchRange = mergeBase ? `${mergeBase}..HEAD` : null; - const [branchLog, recentLog, status, diffStat, stagedDiffStat, unstagedDiffStat, divergence] = + const graphRefs = ["HEAD"]; + if (baseRef) graphRefs.push(baseRef); + const [branchLog, recentLog, graphLog, status, diffStat, stagedDiffStat, unstagedDiffStat, divergence] = await Promise.all([ branchRange ? runGit(worktreeRoot, ["log", "--format=%h %s", branchRange]) : Promise.resolve(""), runGit(worktreeRoot, ["log", "-10", "--format=%h %s", "HEAD"]), + runGit(worktreeRoot, [ + "log", + "--graph", + "--decorate=short", + "--topo-order", + "--format=%h%x09%s%x09%D", + "--max-count=40", + ...graphRefs, + ]), runGit(worktreeRoot, ["status", "--short", "--untracked-files=all"]), runGit(worktreeRoot, ["diff", "--stat", "HEAD"]), runGit(worktreeRoot, ["diff", "--cached", "--stat"]), @@ -89,7 +188,9 @@ export async function gatherGitContext(cwd) { behind, branchCommits: lines(branchLog), recentCommits: lines(recentLog), + commitGraph: lines(graphLog).map(parseGraphLine), uncommitted: lines(status), + changes: lines(status).map(parseStatusEntry), diffStat, stagedDiffStat, unstagedDiffStat, diff --git a/extensions/where-was-i/git-context.test.mjs b/extensions/where-was-i/git-context.test.mjs index 393f53e7e..f0aa9c13a 100644 --- a/extensions/where-was-i/git-context.test.mjs +++ b/extensions/where-was-i/git-context.test.mjs @@ -5,7 +5,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; -import { gatherGitContext } from "./git-context.mjs"; +import { gatherGitContext, getFileDiff } from "./git-context.mjs"; function git(cwd, ...args) { return execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); @@ -54,6 +54,16 @@ test("gathers branch commits and every worktree change", async (t) => { context.branchCommits.map((commit) => commit.replace(/^[0-9a-f]+ /, "")), ["Add second feature commit", "Add first feature commit"], ); + assert.equal(context.commitGraph[0].subject, "Add second feature commit"); + assert.match(context.commitGraph[0].refs, /HEAD -> feature\/context/); + assert.deepEqual( + context.changes.map((change) => [change.code, change.path]), + [ + ["M ", "staged.txt"], + [" M", "unstaged.txt"], + ["??", "untracked.txt"], + ], + ); assert.match(context.uncommitted.join("\n"), /M staged\.txt/); assert.match(context.uncommitted.join("\n"), / M unstaged\.txt/); assert.match(context.uncommitted.join("\n"), /\?\? untracked\.txt/); @@ -61,4 +71,17 @@ test("gathers branch commits and every worktree change", async (t) => { assert.match(context.diffStat, /unstaged\.txt/); assert.match(context.stagedDiffStat, /staged\.txt/); assert.match(context.unstagedDiffStat, /unstaged\.txt/); + + const stagedDiff = await getFileDiff(cwd, "staged.txt"); + assert.equal(stagedDiff.code, "M "); + assert.match(stagedDiff.diff, /\+staged change/); + + const unstagedDiff = await getFileDiff(cwd, "unstaged.txt"); + assert.equal(unstagedDiff.code, " M"); + assert.match(unstagedDiff.diff, /\+unstaged change/); + + const untrackedDiff = await getFileDiff(cwd, "untracked.txt"); + assert.equal(untrackedDiff.code, "??"); + assert.match(untrackedDiff.diff, /new file mode 100644/); + assert.match(untrackedDiff.diff, /\+untracked change/); }); From 71f8be73f6190370bd341345d22b29ee0ac08030 Mon Sep 17 00:00:00 2001 From: James Montemagno Date: Fri, 17 Jul 2026 08:43:29 -0700 Subject: [PATCH 04/10] Add worktree git graph Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2d224e01-e53f-46f8-beb2-0b8b64c5ee89 --- extensions/where-was-i/extension.mjs | 146 ++++++++++++++++++++++----- 1 file changed, 118 insertions(+), 28 deletions(-) diff --git a/extensions/where-was-i/extension.mjs b/extensions/where-was-i/extension.mjs index ce4f160f9..ab82352ff 100644 --- a/extensions/where-was-i/extension.mjs +++ b/extensions/where-was-i/extension.mjs @@ -266,24 +266,85 @@ body { padding: 2rem 1.5rem 3rem; max-width: 880px; margin: 0 auto; } 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; +} + .file-list { list-style: none; } .file-list li { + align-items: center; + border-radius: 6px; + display: flex; + gap: 8px; font-family: var(--mono); font-size: 0.8rem; - padding: 4px 0; + padding: 7px 8px; color: var(--muted); } .file-list .status-badge { display: inline-block; - width: 18px; - text-align: center; - margin-right: 6px; + border-radius: var(--radius-pill); font-weight: 600; + min-width: 76px; + padding: 2px 8px; + text-align: center; +} +.file-list .status-badge.modified { background: #fff7ed; color: #b45309; } +.file-list .status-badge.added { background: var(--sage-tint); color: #4d7c0f; } +.file-list .status-badge.deleted { background: #fef2f2; color: #dc2626; } +.file-list .status-badge.untracked { background: var(--coral-tint); color: #c2410c; } +.file-list .status-badge.renamed { background: var(--azure-tint); color: var(--azure); } +.file-list .file-path { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.file-list .status-badge.M { color: #d97706; } -.file-list .status-badge.A { color: var(--sage); } -.file-list .status-badge.D { color: #ef4444; } -.file-list .status-badge.U { color: var(--coral); } .thread-cards { display: grid; grid-template-columns: 1fr; gap: 0.6rem; } .thread-card { @@ -471,6 +532,18 @@ function escapeHtml(s) { return String(s || "").replace(/&/g,"&").replace(//g,">").replace(/"/g,"""); } +function describeStatus(code) { + if (code === "??") return { label: "Untracked", kind: "untracked" }; + 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 render(data) { contextData = data; const app = document.getElementById("app"); @@ -484,11 +557,12 @@ function render(data) { return { hash, msg }; }); - const files = (data.uncommitted || []).map(f => { - const status = f.substring(0, 2).trim(); - const path = f.substring(3); - return { status, path }; - }); + 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 || []; const prs = data.openPrs || []; const issues = data.assignedIssues || []; @@ -519,20 +593,33 @@ function render(data) {
    \${data.warnings.map(escapeHtml).join("
    ")}
    \` : ""} - \${commits.length ? \` + \${graph.length ? \`
    -
    \${worktreeCommits.length ? "Commits on this worktree" : "Recent commits"}
    -
    -
      - \${commits.map(c => \` -
    • - \${escapeHtml(c.hash)} - \${escapeHtml(c.msg)} -
    • - \`).join("")} -
    +
    Git graph
    +
    + \${graph.map(row => row.hash ? \` +
    + \${escapeHtml(row.graph || "* ")} + \${escapeHtml(row.hash)} + \${escapeHtml(row.subject)} + + \${(row.refs || "").split(",").map(ref => ref.trim()).filter(Boolean).map(ref => \`\${escapeHtml(ref)}\`).join("")} + +
    + \` : \` +
    + \${escapeHtml(row.graph)} +
    + \`).join("")}
    + \` : commits.length ? \` +
    +
    Recent commits
    +
      + \${commits.map(c => \`
    • \${escapeHtml(c.hash)}\${escapeHtml(c.msg)}
    • \`).join("")} +
    +
    \` : ""} \${files.length ? \` @@ -540,12 +627,15 @@ function render(data) {
    Uncommitted Changes
      - \${files.map(f => \` + \${files.map(f => { + const status = describeStatus(f.code); + return \`
    • - \${escapeHtml(f.status)} - \${escapeHtml(f.path)} + \${status.label} + \${escapeHtml(f.path)}
    • - \`).join("")} + \`; + }).join("")}
    \${data.diffStat ? \`
    \${escapeHtml(data.diffStat)}
    \` : ""}
    From b5d2efe49a81d8f882c56a5c3b90bec9fd727ba0 Mon Sep 17 00:00:00 2001 From: James Montemagno Date: Fri, 17 Jul 2026 08:44:37 -0700 Subject: [PATCH 05/10] Add uncommitted change inspector Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2d224e01-e53f-46f8-beb2-0b8b64c5ee89 --- extensions/where-was-i/extension.mjs | 180 +++++++++++++++++++++++++-- 1 file changed, 172 insertions(+), 8 deletions(-) diff --git a/extensions/where-was-i/extension.mjs b/extensions/where-was-i/extension.mjs index ab82352ff..401593fb4 100644 --- a/extensions/where-was-i/extension.mjs +++ b/extensions/where-was-i/extension.mjs @@ -6,7 +6,7 @@ import { execFile } from "node:child_process"; import { writeFile, mkdir } from "node:fs/promises"; import { join } from "node:path"; import { joinSession, createCanvas, CanvasError } from "@github/copilot-sdk/extension"; -import { gatherGitContext } from "./git-context.mjs"; +import { gatherGitContext, getFileDiff } from "./git-context.mjs"; const servers = new Map(); const sseClients = new Map(); // instanceId → Set @@ -327,7 +327,25 @@ body { padding: 2rem 1.5rem 3rem; max-width: 880px; margin: 0 auto; } padding: 7px 8px; color: var(--muted); } -.file-list .status-badge { +.file-list li.file-change { + cursor: pointer; + transition: background 0.12s ease, color 0.12s ease; +} +.file-list li.file-change:hover, +.file-list li.file-change:focus-visible { + background: color-mix(in srgb, var(--azure) 7%, transparent); + color: var(--text); + outline: none; +} +.file-list li.file-change::after { + color: var(--meta); + content: "View diff"; + 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; @@ -335,11 +353,11 @@ body { padding: 2rem 1.5rem 3rem; max-width: 880px; margin: 0 auto; } padding: 2px 8px; text-align: center; } -.file-list .status-badge.modified { background: #fff7ed; color: #b45309; } -.file-list .status-badge.added { background: var(--sage-tint); color: #4d7c0f; } -.file-list .status-badge.deleted { background: #fef2f2; color: #dc2626; } -.file-list .status-badge.untracked { background: var(--coral-tint); color: #c2410c; } -.file-list .status-badge.renamed { background: var(--azure-tint); color: var(--azure); } +.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); } .file-list .file-path { overflow: hidden; text-overflow: ellipsis; @@ -470,6 +488,68 @@ body { padding: 2rem 1.5rem 3rem; max-width: 880px; margin: 0 auto; } 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; @@ -496,6 +576,16 @@ body { padding: 2rem 1.5rem 3rem; max-width: 880px; margin: 0 auto; } Reconstructing your context…
    +
    +
    +
    + Changed + + +
    +
    
    +    
    +