From f34d79f5e2e2cbdbe5dd12fd85cd00b902da4437 Mon Sep 17 00:00:00 2001
From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com>
Date: Fri, 23 Oct 2026 20:56:26 -0700
Subject: [PATCH 1/4] fix(extension): discover projects beyond the recent list
---
plugin/chatgpt-extension/BUILD.bazel | 2 +-
plugin/chatgpt-extension/README.md | 25 +-
plugin/chatgpt-extension/package.json | 1 +
plugin/chatgpt-extension/src/app/main.ts | 106 +++++-
plugin/chatgpt-extension/src/app/styles.css | 4 +-
plugin/chatgpt-extension/src/server/bridge.ts | 23 +-
.../chatgpt-extension/src/server/register.ts | 14 +-
.../src/server/registry-client.ts | 11 +-
.../src/server/view-model.ts | 24 +-
plugin/chatgpt-extension/src/shared/view.ts | 13 +-
plugin/chatgpt-extension/test/adapter.test.ts | 31 +-
.../test/bridge-lifecycle.test.ts | 2 +-
.../test/browser/app.spec.ts | 82 ++++-
.../chatgpt-extension/test/browser/harness.ts | 22 +-
.../chatgpt-extension/test/extension.test.ts | 46 ++-
.../test/project-discovery.test.ts | 264 +++++++++++++++
.../test/project-picker.test.ts | 305 ++++++++++++++++++
pnpm-lock.yaml | 3 +
18 files changed, 922 insertions(+), 56 deletions(-)
create mode 100644 plugin/chatgpt-extension/test/project-discovery.test.ts
create mode 100644 plugin/chatgpt-extension/test/project-picker.test.ts
diff --git a/plugin/chatgpt-extension/BUILD.bazel b/plugin/chatgpt-extension/BUILD.bazel
index 7970655e4b..5aed6ab7e1 100644
--- a/plugin/chatgpt-extension/BUILD.bazel
+++ b/plugin/chatgpt-extension/BUILD.bazel
@@ -21,7 +21,7 @@ tsc_bin.tsc_test(
vitest_bin.vitest_test(
name = "adapter_test",
data = [":sources", ":node_modules", "//sdks/typescript:dist"],
- args = ["run", "test/adapter.test.ts"],
+ args = ["run", "test/adapter.test.ts", "test/project-discovery.test.ts", "test/project-picker.test.ts"],
chdir = package_name(),
)
diff --git a/plugin/chatgpt-extension/README.md b/plugin/chatgpt-extension/README.md
index 4837c48211..b20ae3c27a 100644
--- a/plugin/chatgpt-extension/README.md
+++ b/plugin/chatgpt-extension/README.md
@@ -9,7 +9,8 @@ contracts. It adds no DTOs, database, policy store, or write API.
From a ChatGPT conversation or the plugin's global entrypoint you can:
-- Pick a project from the daemon's isolated profile registry.
+- Pick a recent project or search the daemon's isolated profile registry by
+ project path, alias, id, or default branch.
- Search code symbols by name.
- Inspect a symbol with its callers, callees, impact, and a bounded call
graph.
@@ -44,7 +45,7 @@ not-found-or-not-authorized problem rather than an empty success.
Inside the server a `DaemonBridge` reads through `@tracedecay/sdk` (the
workspace's generated operation contracts, never hand-edited):
-- Registry operations (`project_list`, `project_context`) use a projectless
+- Registry operations (`project_list`, `project_search`, `project_context`) use a projectless
`RegistryClient` over a profile-scoped `tracedecay serve` session; it takes
no base URL, project id, or token.
- MCP-transport project operations (`search`, `node`, `impact`,
@@ -64,12 +65,21 @@ workspace's generated operation contracts, never hand-edited):
- `tracedecay_workspace` (global UI entrypoint): daemon state + project list.
- `tracedecay_thread_panel` (thread/conversation UI entrypoint).
- `tracedecay_list_projects`: up to 10 recent registered projects (app-only).
+- `tracedecay_search_projects`: up to 10 matching registered projects
+ (app-only); an empty query returns to recent projects. Listings preserve
+ the daemon's limit and truncation signal. Summary counts describe the
+ returned page. Refine a search when more matches exist than are shown.
- `tracedecay_search_code`: symbol search, returns node ids, generation, and
freshness.
- `tracedecay_inspect_symbol`: symbol detail with callers/callees/impact,
bounded graph, provenance, and an `openai/deepLink` back into the app.
- `search_mentions`: `@`-mention search, where the host supports mentions.
+The picker keeps search available while a request is pending. Replacing a
+query or navigating cancels the prior MCP request; late responses cannot
+replace the current results or selected project. Mention search continues
+to read only four recent projects and up to eight symbols per project.
+
## Packaging
`plugin/plugin.json` + `plugin/mcp.json` are the portable Agent Plugins
@@ -89,12 +99,17 @@ staged bundle runs against the exact binary that installed it.
## Verification
-- `pnpm typecheck` runs `tsc --noEmit` over server, app, and tests.
+- `pnpm typecheck` runs `tsc --noEmit` over server and shared view types.
+- `pnpm exec vitest run test/adapter.test.ts test/project-discovery.test.ts
+ test/project-picker.test.ts test/serve-client.test.ts
+ test/bridge-lifecycle.test.ts` checks the extension adapters, project
+ discovery, DOM interactions, cancellation, and shutdown without a daemon.
- `pnpm test` runs the vitest integration suite against a real daemon,
covering project registration, search, symbol inspection, callers/callees,
denied/not-found/disconnected states, and the authority token never
- leaving the server. Requires `target/debug/tracedecay`
- (`cargo build -p tracedecay-cli --bin tracedecay`) or `TRACEDECAY_BIN`.
+ leaving the server, including discovery beyond the recent-project page.
+ Set `TRACEDECAY_BIN` to the CLI built with
+ `bazel build //crates/tracedecay-cli:tracedecay__test`.
- `pnpm test:browser` runs the Playwright suite against the real `embedded` app
inside a sandboxed iframe on the maintained `AppBridge` host. It covers
project connection, search, symbol inspection, graph browsing, sending
diff --git a/plugin/chatgpt-extension/package.json b/plugin/chatgpt-extension/package.json
index 38f2566604..d372fe67ac 100644
--- a/plugin/chatgpt-extension/package.json
+++ b/plugin/chatgpt-extension/package.json
@@ -29,6 +29,7 @@
"@rsbuild/core": "^2.2.13",
"@rslib/core": "^1.0.0",
"@types/node": "^26.6.5",
+ "jsdom": "^30.0.0",
"typescript": "^7.0.0",
"vitest": "^5.0.0"
}
diff --git a/plugin/chatgpt-extension/src/app/main.ts b/plugin/chatgpt-extension/src/app/main.ts
index ab6deade3d..50564e039f 100644
--- a/plugin/chatgpt-extension/src/app/main.ts
+++ b/plugin/chatgpt-extension/src/app/main.ts
@@ -1,3 +1,4 @@
+///
import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps";
import type { CallToolResult } from "@modelcontextprotocol/client";
import {
@@ -18,11 +19,11 @@ import "./styles.css";
type Screen =
| { readonly phase: "connecting" }
- | { readonly phase: "loading"; readonly label: string; readonly previous: ViewState | null }
+ | { readonly phase: "loading"; readonly label: string; readonly previous: ViewState | null; readonly projectQuery?: string }
| { readonly phase: "view"; readonly view: ViewState }
| { readonly phase: "host_error"; readonly message: string };
-type ToolName = "tracedecay_workspace" | "tracedecay_list_projects" | "tracedecay_search_code" | "tracedecay_inspect_symbol";
+type ToolName = "tracedecay_workspace" | "tracedecay_list_projects" | "tracedecay_search_projects" | "tracedecay_search_code" | "tracedecay_inspect_symbol";
const root = document.getElementById("root");
if (root === null) throw new Error("app root element missing");
@@ -36,6 +37,14 @@ const app = new App(
let screen: Screen = { phase: "connecting" };
let knownProjects: readonly ProjectRef[] = [];
let lastDeepLink: string | null = null;
+let pendingCall: AbortController | null = null;
+let projectQueryDraft: string | null = null;
+
+function cancelPendingCall(): void {
+ const previous = pendingCall;
+ pendingCall = null;
+ previous?.abort();
+}
function setScreen(next: Screen): void {
screen = next;
@@ -49,10 +58,12 @@ function currentView(): ViewState | null {
}
function rememberProjects(view: ViewState): void {
- if (view.page === "projects" && view.projects.state === "ready") knownProjects = view.projects.data;
+ if (view.page === "projects") knownProjects = view.projects.state === "ready" ? view.projects.data.projects : [];
}
function adoptView(view: ViewState): void {
+ cancelPendingCall();
+ projectQueryDraft = view.page === "projects" ? view.query : null;
rememberProjects(view);
setScreen({ phase: "view", view });
void publishModelContext(view);
@@ -62,10 +73,16 @@ function viewFromResult(result: CallToolResult): ViewState | null {
return isViewState(result.structuredContent) ? result.structuredContent : null;
}
-async function callTool(name: ToolName, args: Record, label: string): Promise {
- setScreen({ phase: "loading", label, previous: currentView() });
+async function callTool(name: ToolName, args: Record, label: string, projectQuery?: string): Promise {
+ cancelPendingCall();
+ const controller = new AbortController();
+ pendingCall = controller;
+ if (projectQuery !== undefined) projectQueryDraft = projectQuery;
+ setScreen({ phase: "loading", label, previous: currentView(), projectQuery });
try {
- const result = await app.callServerTool({ name, arguments: args });
+ const result = await app.callServerTool({ name, arguments: args }, { signal: controller.signal });
+ if (pendingCall !== controller) return;
+ pendingCall = null;
const view = viewFromResult(result);
if (view === null) {
setScreen({ phase: "host_error", message: `${name} returned no view payload` });
@@ -73,6 +90,8 @@ async function callTool(name: ToolName, args: Record, label: st
}
adoptView(view);
} catch (error) {
+ if (pendingCall !== controller) return;
+ pendingCall = null;
setScreen({ phase: "host_error", message: error instanceof Error ? error.message : String(error) });
}
}
@@ -129,7 +148,12 @@ function describeSection(section: Section, ready: (data: T) => string): st
// Navigation -----------------------------------------------------------------
function openWorkspace(): void {
- void callTool("tracedecay_workspace", {}, "Loading registered projects…");
+ void callTool("tracedecay_workspace", {}, "Loading registered projects…", "");
+}
+
+function findProjects(query: string): void {
+ const trimmed = query.trim();
+ void callTool("tracedecay_search_projects", { query: trimmed }, trimmed === "" ? "Loading recent projects…" : `Searching projects for "${trimmed}"…`, trimmed);
}
function openProject(project: ProjectRef): void {
@@ -273,25 +297,63 @@ function renderHeader(view: ViewState | null): HTMLElement {
return bar;
}
-function renderProjects(view: Extract): Node[] {
+function renderProjects(view: Extract, pendingQuery?: string): Node[] {
const daemon =
view.daemon.state === "connected"
? el("p", { class: "notice", "data-testid": "daemon-state" }, [badge("connected", "ok", "daemon-badge"), ` tracedecay ${view.daemon.version} (pid ${view.daemon.pid}) · profile `, el("code", {}, [view.daemon.profile_root])])
: failureNotice(view.daemon.failure, "daemon-state");
- const list = sectionBody(view.projects, "projects", (projects) =>
- el(
+ const list = sectionBody(view.projects, "projects", (listing) => {
+ const rows = el(
"ul",
{ class: "list" },
- projects.map((project) =>
+ listing.projects.map((project) =>
el("li", { "data-testid": "project-row" }, [
button(project.label, () => openProject(project), { class: "primary", "data-testid": "project-open", "data-project-id": project.project_id }),
el("span", { class: "loc" }, [project.project_root]),
project.head_branch === null ? "" : badge(project.head_branch),
]),
),
- ),
- );
- return [daemon, panel("Recent registered projects", "projects-panel", [list, el("p", { class: "notice" }, ["Showing up to 10 recently used projects. Only projects registered in this TraceDecay profile (via `tracedecay init`) can be explored. Nothing here writes to your code."])])];
+ );
+ const count = `${listing.projects.length} project(s) shown (up to ${listing.limit}).`;
+ const completeness = listing.truncated
+ ? `${count} More ${view.query === "" ? "registered projects are available; search to find projects outside this recent list" : "matches are available; refine your search to find a specific project"}.`
+ : `${count} ${view.query === "" ? "All registered projects are shown" : "All matching projects are shown"}.`;
+ return el("div", {}, [notice(completeness, listing.truncated ? "warn" : "neutral", "projects-completeness"), rows]);
+ });
+ const results = panel(view.query === "" ? "Recent registered projects" : `Projects matching "${view.query}"`, "projects-panel", [list]);
+ return [
+ daemon,
+ renderProjectSearchForm(projectQueryDraft ?? pendingQuery ?? view.query),
+ ...(pendingQuery === undefined ? [] : [el("p", { class: "loading", role: "status", "data-testid": "loading" }, [`${screen.phase === "loading" ? screen.label : "Loading projects…"} Previous results remain below.`])]),
+ results,
+ el("p", { class: "notice" }, ["Search registered project paths, aliases, IDs, or default branches. Only projects registered in this TraceDecay profile (via `tracedecay init`) can be explored."]),
+ ];
+}
+
+function renderProjectSearchForm(query: string): HTMLFormElement {
+ const input = el("input", { type: "search", name: "project-query", placeholder: "Find a registered project…", value: query, "data-testid": "project-search-input", "aria-label": "Find registered projects" });
+ const submit = (): void => findProjects(input.value);
+ const form = el("form", { class: "search project-search", "data-testid": "project-search-form", role: "search", "aria-label": "Registered projects" }, [
+ input,
+ button("Find projects", submit, { class: "primary", "data-testid": "project-search-submit" }),
+ button("Recent projects", () => findProjects(""), { "data-testid": "project-search-recent" }),
+ ]);
+ input.addEventListener("input", () => {
+ projectQueryDraft = input.value;
+ if (screen.phase === "loading" && screen.projectQuery !== undefined && screen.previous?.page === "projects") {
+ const previous = screen.previous;
+ cancelPendingCall();
+ setScreen({ phase: "view", view: previous });
+ }
+ });
+ input.addEventListener("keydown", (event) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ submit();
+ }
+ });
+ form.addEventListener("submit", (event) => event.preventDefault());
+ return form;
}
function renderSearchForm(project: ProjectRef, query: string): HTMLFormElement {
@@ -491,6 +553,7 @@ function renderBody(): Node[] {
case "connecting":
return [el("p", { class: "loading", "data-testid": "connecting" }, ["Connecting to ChatGPT…"])];
case "loading":
+ if (screen.previous?.page === "projects" && screen.projectQuery !== undefined) return renderProjects(screen.previous, screen.projectQuery);
return [el("p", { class: "loading", "data-testid": "loading" }, [screen.label])];
case "host_error":
return [failureNotice({ kind: "protocol", code: "host_call_failed", message: screen.message }, "host-error"), el("div", { class: "actions" }, [button("Back to projects", openWorkspace, { class: "primary", "data-testid": "back-to-projects" })])];
@@ -509,17 +572,27 @@ function renderBody(): Node[] {
}
function render(): void {
+ const focused = document.activeElement;
+ const projectInput = focused instanceof HTMLInputElement && focused.dataset["testid"] === "project-search-input" ? focused : null;
+ const selection = projectInput === null ? null : [projectInput.selectionStart, projectInput.selectionEnd] as const;
mount.replaceChildren(renderHeader(currentView()), ...renderBody());
mount.dataset["phase"] = screen.phase;
mount.dataset["page"] = currentView()?.page ?? "";
+ if (selection !== null) {
+ const replacement = mount.querySelector('[data-testid="project-search-input"]');
+ replacement?.focus({ preventScroll: true });
+ replacement?.setSelectionRange(...selection);
+ }
}
// Host wiring ----------------------------------------------------------------
app.ontoolinput = (params) => {
+ cancelPendingCall();
setScreen({ phase: "loading", label: `Loading ${params.arguments?.["query"] !== undefined ? `search for "${String(params.arguments["query"])}"` : "from TraceDecay"}…`, previous: currentView() });
};
app.ontoolresult = (result) => {
+ cancelPendingCall();
const view = viewFromResult(result);
if (view === null) setScreen({ phase: "host_error", message: "The tool result carried no TraceDecay view payload." });
else adoptView(view);
@@ -528,6 +601,11 @@ app.onhostcontextchanged = (context) => {
applyHostContext(context);
render();
};
+app.onteardown = async () => {
+ cancelPendingCall();
+ return {};
+};
+window.addEventListener("pagehide", cancelPendingCall);
render();
app
diff --git a/plugin/chatgpt-extension/src/app/styles.css b/plugin/chatgpt-extension/src/app/styles.css
index a98a6fefcb..655172fa72 100644
--- a/plugin/chatgpt-extension/src/app/styles.css
+++ b/plugin/chatgpt-extension/src/app/styles.css
@@ -41,11 +41,13 @@ button.primary { border: 1px solid var(--td-border); background: var(--td-panel)
button:disabled { opacity: 0.5; cursor: default; }
input[type="search"] { font: inherit; flex: 1; min-width: 160px; padding: 5px 8px; border: 1px solid var(--td-border); border-radius: 6px; background: var(--td-bg); color: var(--td-fg); }
form.search { display: flex; gap: 6px; }
+form.project-search { flex-wrap: wrap; }
+form.project-search input { min-width: 0; flex-basis: 200px; }
section.panel { border: 1px solid var(--td-border); border-radius: 8px; padding: 8px 10px; background: var(--td-panel); display: grid; gap: 6px; }
section.panel h2 { font-size: 12px; margin: 0; text-transform: uppercase; letter-spacing: 0.04em; color: var(--td-muted); display: flex; gap: 6px; align-items: center; }
ul.list { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; }
ul.list li { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; }
-ul.list li .loc { color: var(--td-muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
+ul.list li .loc { color: var(--td-muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; overflow-wrap: anywhere; min-width: 0; }
code, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
.badge { display: inline-block; border-radius: 999px; padding: 1px 8px; font-size: 11px; border: 1px solid var(--td-border); color: var(--td-muted); background: var(--td-bg); }
.badge.ok { color: var(--td-ok); border-color: var(--td-ok); }
diff --git a/plugin/chatgpt-extension/src/server/bridge.ts b/plugin/chatgpt-extension/src/server/bridge.ts
index b55ee7eff0..5f74863195 100644
--- a/plugin/chatgpt-extension/src/server/bridge.ts
+++ b/plugin/chatgpt-extension/src/server/bridge.ts
@@ -13,11 +13,12 @@ import type {
OperationApplicationCodeSymbolSearchResult,
OperationApplicationImpactResult,
OperationApplicationNodeResult,
+ OperationApplicationProjectListResult,
OperationApplicationSearchResult,
OperationApplicationStatusResult,
PublicCodeProject,
} from "@tracedecay/sdk";
-import type { DaemonState, Failure, ProjectRef } from "../shared/view.js";
+import type { DaemonState, Failure, ProjectListing, ProjectRef } from "../shared/view.js";
import { httpBaseUrl, probeDaemon, readDaemonAuthority, type AuthorityLookup, type DaemonAuthorityRecord } from "./authority.js";
import { RegistryClient } from "./registry-client.js";
import { classifyCode, DaemonFailure, ServeSession, toFailure } from "./serve-client.js";
@@ -118,11 +119,18 @@ export class DaemonBridge {
});
}
- async listProjects(signal?: AbortSignal): Promise {
+ async listProjects(signal?: AbortSignal): Promise {
await this.#requireAuthority();
const client = this.#registryClient();
const listed = await this.#runSdk(() => client.projectList({ limit: 10 }, signal));
- return listed.projects.map(projectRef);
+ return projectListing(listed);
+ }
+
+ async searchProjects(query: string, signal?: AbortSignal): Promise {
+ await this.#requireAuthority();
+ const client = this.#registryClient();
+ const listed = await this.#runSdk(() => client.projectSearch({ query, limit: 10 }, signal));
+ return projectListing(listed);
}
async resolveProject(projectId: string, signal?: AbortSignal): Promise {
@@ -394,3 +402,12 @@ export function projectRef(project: PublicCodeProject): ProjectRef {
default_branch: project.default_branch ?? null,
};
}
+
+function projectListing(listed: OperationApplicationProjectListResult): ProjectListing {
+ return {
+ projects: listed.projects.map(projectRef),
+ limit: listed.limit,
+ truncated: listed.truncated,
+ summary: listed.summary,
+ };
+}
diff --git a/plugin/chatgpt-extension/src/server/register.ts b/plugin/chatgpt-extension/src/server/register.ts
index 71c1a06b69..5a330543da 100644
--- a/plugin/chatgpt-extension/src/server/register.ts
+++ b/plugin/chatgpt-extension/src/server/register.ts
@@ -80,6 +80,18 @@ export function registerTraceDecayExtension({ server, bridge, html }: RegisterOp
async (_args, extra) => toolResult(await projectsView(bridge, extra.mcpReq.signal)),
);
+ registerAppTool(server,
+ "tracedecay_search_projects",
+ {
+ title: "Search TraceDecay projects",
+ description: "Find up to ten registered projects by path, alias, id, or default branch. An empty query shows recent projects.",
+ inputSchema: z.object({ query: z.string().describe("Project metadata query; empty to show recent projects") }),
+ annotations: readOnly,
+ _meta: appOnly,
+ },
+ async ({ query }, extra) => toolResult(await projectsView(bridge, extra.mcpReq.signal, query)),
+ );
+
registerAppTool(server,
"tracedecay_search_code",
{
@@ -123,7 +135,7 @@ export function registerTraceDecayExtension({ server, bridge, html }: RegisterOp
const extensions = new OpenAIExtensions(server);
extensions.mentions.setHandler(async ({ query }, extra) => {
if (query.trim().length === 0) return { items: [] };
- const projects = (await bridge.listProjects(extra.mcpReq.signal)).slice(0, MENTION_PROJECT_LIMIT);
+ const projects = (await bridge.listProjects(extra.mcpReq.signal)).projects.slice(0, MENTION_PROJECT_LIMIT);
const perProject = await Promise.allSettled(
projects.map(async (project) => {
const result = await bridge.symbolSearch(project.project_id, query, extra.mcpReq.signal);
diff --git a/plugin/chatgpt-extension/src/server/registry-client.ts b/plugin/chatgpt-extension/src/server/registry-client.ts
index 1379f01e4b..63aacdcdcb 100644
--- a/plugin/chatgpt-extension/src/server/registry-client.ts
+++ b/plugin/chatgpt-extension/src/server/registry-client.ts
@@ -7,6 +7,8 @@ import {
type OperationApplicationProjectContextResult,
type OperationApplicationProjectListRequest,
type OperationApplicationProjectListResult,
+ type OperationApplicationProjectSearchRequest,
+ type OperationApplicationProjectSearchResult,
} from "@tracedecay/sdk";
/** Executes a generated MCP tool binding against a projectless daemon session. */
@@ -28,6 +30,7 @@ function registryDescriptor(operation: string): RegistryDescriptor("project_list");
+const PROJECT_SEARCH = registryDescriptor("project_search");
const PROJECT_CONTEXT = registryDescriptor("project_context");
/**
@@ -51,14 +54,20 @@ export class RegistryClient {
return this.#invoke(PROJECT_CONTEXT, request, signal);
}
+ projectSearch(request: OperationApplicationProjectSearchRequest, signal?: AbortSignal): Promise {
+ return this.#invoke(PROJECT_SEARCH, request, signal);
+ }
+
async #invoke(descriptor: RegistryDescriptor, request: Req, signal?: AbortSignal): Promise {
const toolName = descriptor.transport.toolName as string;
const decoded = descriptor.decodeRequest(request) as Record;
let response: unknown;
try {
+ if (signal?.aborted) throw new TraceDecayAbortError(signal.reason);
response = await this.#call(toolName, decoded, signal);
+ if (signal?.aborted) throw new TraceDecayAbortError(signal.reason);
} catch (cause) {
- if (signal?.aborted) throw new TraceDecayAbortError(signal.reason ?? cause);
+ if (signal?.aborted) throw new TraceDecayAbortError(signal.reason);
throw new TraceDecayTransportError(`MCP tool ${toolName} failed`, cause);
}
try {
diff --git a/plugin/chatgpt-extension/src/server/view-model.ts b/plugin/chatgpt-extension/src/server/view-model.ts
index 0b7bfab7fa..895ab29837 100644
--- a/plugin/chatgpt-extension/src/server/view-model.ts
+++ b/plugin/chatgpt-extension/src/server/view-model.ts
@@ -19,6 +19,7 @@ import type {
GraphEdge,
GraphNode,
ImpactReport,
+ ProjectListing,
ProjectRef,
Provenance,
Relation,
@@ -49,17 +50,26 @@ async function section(load: () => Promise>): Promise>
}
}
-export async function projectsView(bridge: DaemonBridge, signal?: AbortSignal): Promise {
- const projects = await section(async () => {
- const listed = await bridge.listProjects(signal);
- return listed.length === 0
- ? { state: "empty", message: "No projects are registered in this TraceDecay profile. Run `tracedecay init` in a repository first." }
+export async function projectsView(bridge: DaemonBridge, signal?: AbortSignal, query = ""): Promise {
+ signal?.throwIfAborted();
+ const normalizedQuery = query.trim();
+ const projects = await section(async () => {
+ const listed = normalizedQuery === "" ? await bridge.listProjects(signal) : await bridge.searchProjects(normalizedQuery, signal);
+ return listed.projects.length === 0
+ ? {
+ state: "empty",
+ message: normalizedQuery === ""
+ ? "No projects are registered in this TraceDecay profile. Run `tracedecay init` in a repository first."
+ : `No registered projects match "${normalizedQuery}". Try a project path, alias, id, or default branch.`,
+ }
: { state: "ready", data: listed };
});
+ signal?.throwIfAborted();
// Read after the registry call: `tracedecay serve` may have brought the
// daemon back, and the banner must describe the daemon that answered.
const daemon = await bridge.daemonState();
- return { page: "projects", daemon, projects };
+ signal?.throwIfAborted();
+ return { page: "projects", daemon, query: normalizedQuery, projects };
}
export async function failureView(bridge: DaemonBridge, projectId: string, error: unknown): Promise {
@@ -433,7 +443,7 @@ export function viewText(view: ViewState): string {
switch (view.page) {
case "projects":
return view.projects.state === "ready"
- ? `Recent TraceDecay projects: ${view.projects.data.map((project) => `${project.label} (${project.project_id})`).join(", ")}`
+ ? `${view.query === "" ? "Recent TraceDecay projects" : `TraceDecay projects matching "${view.query}"`}: ${view.projects.data.projects.map((project) => `${project.label} (${project.project_id})`).join(", ")}${view.projects.data.truncated ? ` (showing up to ${view.projects.data.limit}; more ${view.query === "" ? "projects are available — search to find them" : "matches are available — refine the query"})` : ""}`
: view.projects.state === "empty"
? view.projects.message
: `TraceDecay projects unavailable (${view.projects.failure.kind}): ${view.projects.failure.message}`;
diff --git a/plugin/chatgpt-extension/src/shared/view.ts b/plugin/chatgpt-extension/src/shared/view.ts
index c452146f04..88748adca4 100644
--- a/plugin/chatgpt-extension/src/shared/view.ts
+++ b/plugin/chatgpt-extension/src/shared/view.ts
@@ -1,6 +1,7 @@
// View state shared by the MCP server (producer) and the MCP App (consumer).
// Every variant is a truthful daemon-backed state; the app never synthesizes
// data the daemon did not report.
+import type { ProjectRegistrySummary } from "@tracedecay/sdk";
export type ProjectRef = {
readonly project_id: string;
@@ -10,6 +11,14 @@ export type ProjectRef = {
readonly default_branch: string | null;
};
+export type ProjectListing = {
+ readonly projects: readonly ProjectRef[];
+ readonly limit: number;
+ readonly truncated: boolean;
+ /** Canonical counts describe this returned page, not the entire registry. */
+ readonly summary: ProjectRegistrySummary;
+};
+
export type FreshnessState =
| "current"
| "stale"
@@ -152,7 +161,9 @@ export type ViewState =
| {
readonly page: "projects";
readonly daemon: DaemonState;
- readonly projects: Section;
+ /** An empty query selects the recent-project listing. */
+ readonly query: string;
+ readonly projects: Section;
}
| {
readonly page: "search";
diff --git a/plugin/chatgpt-extension/test/adapter.test.ts b/plugin/chatgpt-extension/test/adapter.test.ts
index f7ae66c805..2bd613097b 100644
--- a/plugin/chatgpt-extension/test/adapter.test.ts
+++ b/plugin/chatgpt-extension/test/adapter.test.ts
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
import { request as httpRequest } from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
-import { afterEach, describe, expect, it } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
import { DaemonBridge } from "../src/server/bridge.js";
import { createExtensionServer, serveLoopbackHttp } from "../src/server/main.js";
import { DaemonFailure, ServeSession } from "../src/server/serve-client.js";
@@ -161,8 +161,14 @@ describe("composer mentions", () => {
{ project_id: "proj_b", label: "shipping" },
];
- async function mentions(symbolSearch: (projectId: string) => Promise): Promise {
- const bridge = { listProjects: async () => projects, symbolSearch } as unknown as DaemonBridge;
+ async function mentions(symbolSearch: (projectId: string) => Promise, availableProjects = projects): Promise {
+ const bridge = {
+ listProjects: async () => ({
+ projects: availableProjects, limit: 10, truncated: false,
+ summary: { project_count: availableProjects.length, repo_count: availableProjects.length, truncated: false },
+ }),
+ symbolSearch,
+ } as unknown as DaemonBridge;
const server = createExtensionServer({ bridge, assets: ASSETS });
const [clientSide, serverSide] = InMemoryTransport.createLinkedPair();
await server.connect(serverSide);
@@ -190,4 +196,23 @@ describe("composer mentions", () => {
expect(result.isError ?? false).toBe(false);
expect(result.structuredContent).toEqual({ items: [] });
});
+
+ it("keeps mention discovery to four recent projects and eight items per project", async () => {
+ const available = Array.from({ length: 10 }, (_, index) => ({ project_id: `proj_${index}`, label: `project-${index}` }));
+ const symbolSearch = vi.fn(async (_projectId: string) => ({
+ items: Array.from({ length: 12 }, (_, index) => ({
+ node_id: `node_${index}`, qualified_name: `render${index}`, kind: "function",
+ name: `render${index}`, file: "src/render.ts", line: index + 1,
+ })),
+ }));
+ const result = await mentions(symbolSearch, available);
+ expect(result.isError ?? false).toBe(false);
+ expect(symbolSearch.mock.calls.map((args) => args[0])).toEqual(available.slice(0, 4).map((project) => project.project_id));
+ const items = (result.structuredContent as { items: { uri: string }[] }).items;
+ expect(items).toHaveLength(32);
+ for (const project of available.slice(0, 4)) {
+ expect(items.filter((item) => item.uri.startsWith(`tracedecay://projects/${project.project_id}/`))).toHaveLength(8);
+ }
+ expect(items.some((item) => item.uri.endsWith("/node_8"))).toBe(false);
+ });
});
diff --git a/plugin/chatgpt-extension/test/bridge-lifecycle.test.ts b/plugin/chatgpt-extension/test/bridge-lifecycle.test.ts
index b72883f64a..098b98ea61 100644
--- a/plugin/chatgpt-extension/test/bridge-lifecycle.test.ts
+++ b/plugin/chatgpt-extension/test/bridge-lifecycle.test.ts
@@ -78,7 +78,7 @@ describe("bridge shutdown", () => {
const fetch = vi.spyOn(globalThis, "fetch");
await bridge.close();
const reads = [
- bridge.authority(), bridge.daemonState(), bridge.listProjects(), bridge.resolveProject(PROJECT_ID),
+ bridge.authority(), bridge.daemonState(), bridge.listProjects(), bridge.searchProjects("query"), bridge.resolveProject(PROJECT_ID),
bridge.status(PROJECT_ID), bridge.search(PROJECT_ID, "query", 10), bridge.node(PROJECT_ID, "n1"),
bridge.impact(PROJECT_ID, "n1", 1), bridge.callers(PROJECT_ID, "n1", 1),
bridge.callees(PROJECT_ID, "n1", "generation", 1), bridge.symbolSearch(PROJECT_ID, "query"),
diff --git a/plugin/chatgpt-extension/test/browser/app.spec.ts b/plugin/chatgpt-extension/test/browser/app.spec.ts
index d8a8f42e43..6e46052024 100644
--- a/plugin/chatgpt-extension/test/browser/app.spec.ts
+++ b/plugin/chatgpt-extension/test/browser/app.spec.ts
@@ -21,6 +21,7 @@ let client: Client;
let assets: ServerAssets;
let staticServer: Server;
let baseUrl: string;
+let emptyRegistryResult: CallToolResult;
test.beforeAll(async () => {
assets = {
@@ -29,15 +30,16 @@ test.beforeAll(async () => {
};
fixture = await DaemonFixture.create();
await fixture.startDaemon();
+ bridge = new DaemonBridge({ binary: fixture.binary, profileRoot: fixture.profileRoot, env: fixture.env, cwd: fixture.home });
+ client = await connectClient(bridge);
+ emptyRegistryResult = await callTool("tracedecay_workspace", {});
billing = await fixture.createRepo("billing", {
"src/billing.ts": BILLING_SOURCE,
"src/report.ts": REPORT_SOURCE,
"src/index.ts": INDEX_SOURCE,
});
fixture.initProject(billing.root);
- bridge = new DaemonBridge({ binary: fixture.binary, profileRoot: fixture.profileRoot, env: fixture.env, cwd: fixture.home });
- client = await connectClient(bridge);
- const project = (await bridge.listProjects()).find((candidate) => candidate.project_root === billing.root);
+ const project = (await bridge.listProjects()).projects.find((candidate) => candidate.project_root === billing.root);
if (project === undefined) throw new Error("billing project was not registered");
billingProjectId = project.project_id;
@@ -91,8 +93,8 @@ async function connectClient(viaBridge: DaemonBridge): Promise {
return mcp;
}
-async function callTool(name: string, args: Record, via: Client = client): Promise {
- return (await via.callTool({ name, arguments: args })) as CallToolResult;
+async function callTool(name: string, args: Record, via: Client = client, signal?: AbortSignal): Promise {
+ return (await via.callTool({ name, arguments: args }, { signal })) as CallToolResult;
}
async function openHost(page: Page): Promise {
@@ -100,7 +102,20 @@ async function openHost(page: Page): Promise {
page.on("console", (message) => {
if (message.type() === "error" || message.type() === "warning") console.error(`[console ${message.type()}]`, message.text());
});
- await page.exposeFunction("__mcpCall", (name: string, args: Record) => callTool(name, args));
+ const pending = new Map();
+ await page.exposeFunction("__mcpCall", async (requestId: number, name: string, args: Record) => {
+ const controller = new AbortController();
+ pending.set(requestId, controller);
+ try {
+ return await callTool(name, args, client, controller.signal);
+ } finally {
+ pending.delete(requestId);
+ }
+ });
+ await page.exposeFunction("__mcpCancel", (requestId: number) => pending.get(requestId)?.abort());
+ page.once("close", () => {
+ for (const controller of pending.values()) controller.abort();
+ });
await page.goto(`${baseUrl}/harness.html`);
await page.evaluate(() => window.__host.ready);
}
@@ -184,6 +199,9 @@ test("denied, not-found, and disconnected states render truthfully", async ({ pa
await openHost(page);
const ui = app(page);
+ await pushToolResult(page, emptyRegistryResult);
+ await expect(ui.getByTestId("projects-empty")).toContainText("No projects are registered");
+ await expect(ui.getByTestId("project-search-input")).toBeVisible();
await pushToolResult(page, await callTool("tracedecay_thread_panel", {}));
await expect(ui.getByTestId("daemon-badge")).toHaveText("connected");
await expect(ui.getByTestId("project-row").filter({ hasText: billing.root })).toHaveCount(1);
@@ -209,3 +227,55 @@ test("denied, not-found, and disconnected states render truthfully", async ({ pa
await offline.close();
}
});
+
+test("finds a registered project outside the recent ten and selects its exact identity", async ({ page }) => {
+ const roots: string[] = [];
+ for (let index = 0; index < 11; index += 1) {
+ const project = await fixture.createRepo(`discovery-${String(index).padStart(2, "0")}`, { "src/index.ts": "export const registered = true;\n" });
+ fixture.initProject(project.root);
+ roots.push(project.root);
+ }
+ const recentResult = await callTool("tracedecay_workspace", {});
+ const recent = recentResult.structuredContent as Extract;
+ expect(recent.projects.state).toBe("ready");
+ if (recent.projects.state !== "ready") throw new Error(JSON.stringify(recent));
+ expect(recent.projects.data.limit).toBe(10);
+ expect(recent.projects.data.truncated).toBe(true);
+ expect(recent.projects.data.projects).toHaveLength(10);
+ const shownRoots = new Set(recent.projects.data.projects.map((project) => project.project_root));
+ const hiddenRoot = roots.find((root) => !shownRoots.has(root));
+ if (hiddenRoot === undefined) throw new Error("fixture did not place a registered project outside the recent list");
+
+ await openHost(page);
+ const ui = app(page);
+ await pushToolResult(page, recentResult);
+ await expect(ui.getByTestId("projects-completeness")).toContainText("More registered projects are available");
+ await expect(ui.getByTestId("project-row").filter({ hasText: hiddenRoot })).toHaveCount(0);
+ await ui.getByTestId("project-search-input").fill(hiddenRoot);
+ await ui.getByTestId("project-search-input").press("Enter");
+ const found = ui.getByTestId("project-row").filter({ hasText: hiddenRoot });
+ await expect(found).toHaveCount(1);
+ await expect(ui.getByTestId("projects-completeness")).toContainText("All matching projects are shown");
+ const projectId = await found.getByTestId("project-open").getAttribute("data-project-id");
+ expect(projectId).toBeTruthy();
+ await found.getByTestId("project-open").click();
+ await expect(ui.getByTestId("results-empty")).toContainText("Type a symbol name");
+ await expect.poll(async () => page.evaluate(() => window.__host.record.modelContext.at(-1))).toMatchObject({
+ structuredContent: { project_id: projectId },
+ });
+
+ await ui.getByTestId("crumb-projects").click();
+ await expect(ui.getByTestId("project-search-input")).toBeVisible();
+ await ui.getByTestId("project-search-input").fill("discovery-");
+ await ui.getByTestId("project-search-submit").click();
+ await expect(ui.getByTestId("projects-completeness")).toContainText("More matches are available");
+ await expect(ui.getByTestId("project-row")).toHaveCount(10);
+
+ await ui.getByTestId("project-search-input").fill("no-such-registered-project");
+ await ui.getByTestId("project-search-submit").click();
+ await expect(ui.getByTestId("projects-empty")).toContainText("No registered projects match");
+ await expect(ui.getByTestId("projects-failed")).toHaveCount(0);
+ await ui.getByTestId("project-search-recent").click();
+ await expect(ui.getByTestId("project-search-input")).toHaveValue("");
+ await expect(ui.getByTestId("projects-panel")).toContainText("Recent registered projects");
+});
diff --git a/plugin/chatgpt-extension/test/browser/harness.ts b/plugin/chatgpt-extension/test/browser/harness.ts
index 389b236152..f214dce428 100644
--- a/plugin/chatgpt-extension/test/browser/harness.ts
+++ b/plugin/chatgpt-extension/test/browser/harness.ts
@@ -16,7 +16,8 @@ type HostRecord = {
declare global {
interface Window {
- __mcpCall: (name: string, args: Record) => Promise;
+ __mcpCall: (requestId: number, name: string, args: Record) => Promise;
+ __mcpCancel: (requestId: number) => Promise;
__host: {
record: HostRecord;
sendToolResult: (result: CallToolResult) => Promise;
@@ -51,9 +52,24 @@ const bridge = new AppBridge(
{ hostContext },
);
-bridge.oncalltool = async (params) => {
+let nextRequestId = 0;
+bridge.oncalltool = async (params, extra) => {
+ const signal = extra.mcpReq.signal;
+ signal.throwIfAborted();
+ const requestId = ++nextRequestId;
+ let cancellation: Promise | undefined;
+ const onAbort = (): void => {
+ cancellation = window.__mcpCancel(requestId);
+ void cancellation.catch(() => undefined);
+ };
+ signal.addEventListener("abort", onAbort, { once: true });
note(`tools/call ${params.name} ${JSON.stringify(params.arguments ?? {})}`);
- return window.__mcpCall(params.name, params.arguments ?? {});
+ try {
+ return await window.__mcpCall(requestId, params.name, params.arguments ?? {});
+ } finally {
+ signal.removeEventListener("abort", onAbort);
+ await cancellation;
+ }
};
bridge.onupdatemodelcontext = async (params) => {
record.modelContext.push(params);
diff --git a/plugin/chatgpt-extension/test/extension.test.ts b/plugin/chatgpt-extension/test/extension.test.ts
index fcb519bff1..096a9dd484 100644
--- a/plugin/chatgpt-extension/test/extension.test.ts
+++ b/plugin/chatgpt-extension/test/extension.test.ts
@@ -96,7 +96,7 @@ beforeAll(async () => {
client = await newClient();
const projects = await waitFor("registered fixture projects", async () => {
const state = await call("tracedecay_workspace");
- return state.page === "projects" && state.projects.state === "ready" ? state.projects.data : null;
+ return state.page === "projects" && state.projects.state === "ready" ? state.projects.data.projects : null;
});
const billingProject = projects.find((project) => project.project_root === billing.root);
const shippingProject = projects.find((project) => project.project_root === shipping.root);
@@ -121,6 +121,7 @@ describe("ChatGPT extension against a live TraceDecay daemon", () => {
"tracedecay_inspect_symbol",
"tracedecay_list_projects",
"tracedecay_search_code",
+ "tracedecay_search_projects",
"tracedecay_thread_panel",
"tracedecay_workspace",
]);
@@ -130,6 +131,7 @@ describe("ChatGPT extension against a live TraceDecay daemon", () => {
expect(workspace._meta?.["ui/resourceUri"]).toBe(UI_RESOURCE_URI);
expect(thread._meta).toMatchObject({ ui: { resourceUri: UI_RESOURCE_URI }, "openai/ui": { entrypoints: [{ type: "thread" }] } });
expect(byName.get("tracedecay_list_projects")!._meta).toMatchObject({ ui: { visibility: ["app"] } });
+ expect(byName.get("tracedecay_search_projects")!._meta).toMatchObject({ ui: { visibility: ["app"] } });
for (const tool of tools.tools) expect(tool.annotations?.readOnlyHint ?? tool.name === "search_mentions").toBeTruthy();
const resources = await client.listResources();
@@ -173,6 +175,7 @@ describe("ChatGPT extension against a live TraceDecay daemon", () => {
"tracedecay_inspect_symbol",
"tracedecay_list_projects",
"tracedecay_search_code",
+ "tracedecay_search_projects",
"tracedecay_thread_panel",
"tracedecay_workspace",
]);
@@ -186,7 +189,7 @@ describe("ChatGPT extension against a live TraceDecay daemon", () => {
if (!isViewState(state) || state.page !== "projects") throw new Error(JSON.stringify(state));
expect(state.daemon.state).toBe("connected");
if (state.projects.state !== "ready") throw new Error(JSON.stringify(state.projects));
- expect(state.projects.data.map((project) => project.project_root).sort()).toEqual([billing.root, shipping.root].sort());
+ expect(state.projects.data.projects.map((project) => project.project_root).sort()).toEqual([billing.root, shipping.root].sort());
} finally {
await packaged.close();
const uninstall = spawnSync(fixture.binary, ["uninstall", "--agent", "chatgpt"], {
@@ -201,10 +204,10 @@ describe("ChatGPT extension against a live TraceDecay daemon", () => {
if (state.page !== "projects") throw new Error(`unexpected page ${state.page}`);
expect(state.daemon.state).toBe("connected");
if (state.projects.state !== "ready") throw new Error(`projects ${state.projects.state}: ${JSON.stringify(state.projects)}`);
- const roots = state.projects.data.map((project) => project.project_root).sort();
+ const roots = state.projects.data.projects.map((project) => project.project_root).sort();
expect(roots).toEqual([billing.root, shipping.root].sort());
- expect(state.projects.data.find((project) => project.project_root === billing.root)!.project_id).toBe(billingProjectId);
- expect(state.projects.data.find((project) => project.project_root === shipping.root)!.project_id).toBe(shippingProjectId);
+ expect(state.projects.data.projects.find((project) => project.project_root === billing.root)!.project_id).toBe(billingProjectId);
+ expect(state.projects.data.projects.find((project) => project.project_root === shipping.root)!.project_id).toBe(shippingProjectId);
expect(billingProjectId).toMatch(/^proj_/u);
expect(shippingProjectId).not.toBe(billingProjectId);
});
@@ -463,7 +466,7 @@ export function taxTotal(invoices: Invoice[]): number {
const repo = await fixture.createRepo("retired", { "src/billing.ts": BILLING_SOURCE });
fixture.initProject(repo.root);
fixture.sync(repo.root);
- const project = (await bridge.listProjects()).find((entry) => entry.project_root === repo.root)!;
+ const project = (await bridge.listProjects()).projects.find((entry) => entry.project_root === repo.root)!;
await bridge.search(project.project_id, "computeTotal", 20);
await fixture.stopDaemon();
const forgotten = spawnSync(fixture.binary, ["--yes", "projects", "forget", project.project_id], {
@@ -471,17 +474,42 @@ export function taxTotal(invoices: Invoice[]): number {
});
expect(forgotten.status, forgotten.stderr).toBe(0);
await fixture.startDaemon();
- expect((await bridge.listProjects()).some((entry) => entry.project_id === project.project_id)).toBe(false);
+ expect((await bridge.listProjects()).projects.some((entry) => entry.project_id === project.project_id)).toBe(false);
await expect(bridge.resolveProject(project.project_id)).rejects.toMatchObject({ failure: { kind: "denied", code: "project_not_registered" } });
await expect(bridge.search(project.project_id, "computeTotal", 20)).rejects.toMatchObject({ failure: { kind: "denied", code: "project_not_registered" } });
});
- it("resolves registered projects beyond the registry's bounded listing", async () => {
+ it("discovers and selects registered projects beyond the bounded recent listing", async () => {
for (let index = 0; index < 26; index += 1) {
const repo = await fixture.createRepo(`listing-${index}`, { "src/shipping.ts": SECOND_PROJECT_SOURCE });
fixture.initProject(repo.root);
}
- expect((await bridge.listProjects()).some((entry) => entry.project_id === billingProjectId)).toBe(false);
+
+ const recent = await call("tracedecay_list_projects");
+ if (recent.page !== "projects" || recent.projects.state !== "ready") throw new Error(JSON.stringify(recent));
+ expect(recent.query).toBe("");
+ expect(recent.projects.data).toMatchObject({ limit: 10, truncated: true, summary: { project_count: 10, truncated: true } });
+ expect(recent.projects.data.projects).toHaveLength(10);
+ expect(recent.projects.data.projects.some((entry) => entry.project_id === billingProjectId)).toBe(false);
+
+ const found = await call("tracedecay_search_projects", { query: "BiLlInG" });
+ if (found.page !== "projects" || found.projects.state !== "ready") throw new Error(JSON.stringify(found));
+ expect(found.query).toBe("BiLlInG");
+ expect(found.projects.data).toMatchObject({ limit: 10, truncated: false });
+ const selected = found.projects.data.projects.find((entry) => entry.project_root === billing.root);
+ expect(selected).toMatchObject({ project_id: billingProjectId, project_root: billing.root });
+ if (selected === undefined) throw new Error("older billing project was not found");
+ const opened = await call("tracedecay_search_code", { project_id: selected.project_id, query: "" });
+ expect(opened).toMatchObject({ page: "search", project: { project_id: billingProjectId, project_root: billing.root } });
+
+ const broad = await call("tracedecay_search_projects", { query: "listing-" });
+ if (broad.page !== "projects" || broad.projects.state !== "ready") throw new Error(JSON.stringify(broad));
+ expect(broad.projects.data).toMatchObject({ limit: 10, truncated: true, summary: { project_count: 10, truncated: true } });
+ expect(broad.projects.data.projects).toHaveLength(10);
+ const missing = await call("tracedecay_search_projects", { query: "no-such-registered-project" });
+ expect(missing).toMatchObject({ page: "projects", query: "no-such-registered-project", projects: { state: "empty" } });
+
+ // A known id remains usable independently of the bounded discovery list.
expect(await bridge.resolveProject(billingProjectId)).toMatchObject({ project_id: billingProjectId, project_root: billing.root });
expect((await bridge.search(billingProjectId, "computeTotal", 20)).results.length).toBeGreaterThan(0);
});
diff --git a/plugin/chatgpt-extension/test/project-discovery.test.ts b/plugin/chatgpt-extension/test/project-discovery.test.ts
new file mode 100644
index 0000000000..9f6b4721b0
--- /dev/null
+++ b/plugin/chatgpt-extension/test/project-discovery.test.ts
@@ -0,0 +1,264 @@
+import { Client, InMemoryTransport, type CallToolResult } from "@modelcontextprotocol/client";
+import {
+ TraceDecayAbortError,
+ TraceDecayMalformedResponseError,
+ type OperationApplicationProjectListResult,
+ type PublicCodeProject,
+} from "@tracedecay/sdk";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type { DaemonAuthorityRecord } from "../src/server/authority.js";
+import { DaemonBridge } from "../src/server/bridge.js";
+import { createExtensionServer } from "../src/server/main.js";
+import { RegistryClient } from "../src/server/registry-client.js";
+import { DaemonFailure, ServeSession } from "../src/server/serve-client.js";
+import { projectsView, viewText } from "../src/server/view-model.js";
+import type { ProjectListing, ViewState } from "../src/shared/view.js";
+
+const ASSETS = { html: "", iconSvg: "" };
+const PROJECT: PublicCodeProject = {
+ project_id: "proj_older",
+ label: "older-billing",
+ project_root: "/isolated-profile/older-billing",
+ canonical_root: "/isolated-profile/older-billing",
+ display_root: "/isolated-profile/older-billing",
+ created_at: 1,
+ last_seen_at: 2,
+ head_branch: "main",
+ default_branch: "main",
+};
+const AUTHORITY: DaemonAuthorityRecord = {
+ pid: process.pid,
+ process_run_id: "isolated-registry-session",
+ started_at_unix_secs: 1,
+ epoch: 1,
+ version: "test",
+ endpoint: null,
+ http_application_endpoint: null,
+ auth_token: "isolated-fixture-token",
+ profile_root: "/isolated-profile",
+};
+const cleanups: Array<() => Promise> = [];
+
+afterEach(async () => {
+ try {
+ for (const cleanup of cleanups.splice(0).reverse()) await cleanup();
+ } finally {
+ vi.restoreAllMocks();
+ }
+});
+
+function listing(overrides: Partial = {}): OperationApplicationProjectListResult {
+ return {
+ status: "ok",
+ title: "Registered projects",
+ registry_path: "/isolated-profile/global.db",
+ limit: 10,
+ truncated: false,
+ summary: { project_count: 1, repo_count: 1, truncated: false },
+ project_tree: [],
+ projects: [PROJECT],
+ ...overrides,
+ };
+}
+
+function bridge(): DaemonBridge {
+ const value = new DaemonBridge({ binary: "/unused-tracedecay", profileRoot: AUTHORITY.profile_root, cwd: AUTHORITY.profile_root, env: {} });
+ vi.spyOn(value, "authority").mockResolvedValue({ kind: "available", record: AUTHORITY });
+ vi.spyOn(value, "daemonState").mockResolvedValue({ state: "connected", pid: AUTHORITY.pid, version: AUTHORITY.version, profile_root: AUTHORITY.profile_root });
+ cleanups.push(() => value.close());
+ return value;
+}
+
+async function client(via: DaemonBridge): Promise {
+ const server = createExtensionServer({ bridge: via, assets: ASSETS });
+ const [clientSide, serverSide] = InMemoryTransport.createLinkedPair();
+ await server.connect(serverSide);
+ const value = new Client({ name: "project-discovery-test", version: "0.0.0" });
+ await value.connect(clientSide);
+ cleanups.push(async () => {
+ await value.close();
+ await server.close();
+ });
+ return value;
+}
+
+function projectView(result: CallToolResult): Extract {
+ expect(result.isError ?? false, JSON.stringify(result)).toBe(false);
+ const value = result.structuredContent as ViewState;
+ if (value?.page !== "projects") throw new Error(`expected project view: ${JSON.stringify(result)}`);
+ return value;
+}
+
+function ready(view: Extract): ProjectListing {
+ if (view.projects.state !== "ready") throw new Error(`expected project listing: ${JSON.stringify(view)}`);
+ return view.projects.data;
+}
+
+describe("canonical project discovery", () => {
+ it("uses the generated project-search binding and preserves the registry result", async () => {
+ const response = listing({ query: "older-billing", truncated: true, summary: { project_count: 1, repo_count: 1, truncated: true } });
+ const call = vi.fn().mockResolvedValue(response);
+ const registry = new RegistryClient(call);
+ const controller = new AbortController();
+ await expect(registry.projectSearch({ query: "older-billing", limit: 10 }, controller.signal)).resolves.toEqual(response);
+ expect(call).toHaveBeenCalledExactlyOnceWith("tracedecay_project_search", { query: "older-billing", limit: 10 }, controller.signal);
+ });
+
+ it("rejects malformed search results through the generated decoder", async () => {
+ const call = vi.fn().mockResolvedValue({ ...listing(), truncated: "unknown" });
+ const registry = new RegistryClient(call);
+ await expect(registry.projectSearch({ query: "older", limit: 10 })).rejects.toBeInstanceOf(TraceDecayMalformedResponseError);
+ });
+
+ it("keeps cancellation typed at the registry boundary", async () => {
+ const reason = new Error("replaced project query");
+ const controller = new AbortController();
+ const registry = new RegistryClient(async (_name, _request, signal) => {
+ expect(signal).toBe(controller.signal);
+ controller.abort(reason);
+ throw reason;
+ });
+ await expect(registry.projectSearch({ query: "older", limit: 10 }, controller.signal)).rejects.toMatchObject({
+ name: TraceDecayAbortError.name,
+ cause: reason,
+ });
+ });
+
+ it("does not dispatch an already cancelled registry request and preserves a null reason", async () => {
+ const call = vi.fn().mockResolvedValue(listing());
+ const controller = new AbortController();
+ controller.abort(null);
+ await expect(new RegistryClient(call).projectSearch({ query: "older", limit: 10 }, controller.signal))
+ .rejects.toMatchObject({ name: TraceDecayAbortError.name, cause: null });
+ expect(call).not.toHaveBeenCalled();
+ });
+
+ it("rejects a late result from a registry caller that ignored cancellation", async () => {
+ const controller = new AbortController();
+ const registry = new RegistryClient(async () => {
+ controller.abort(null);
+ return listing();
+ });
+ await expect(registry.projectSearch({ query: "older", limit: 10 }, controller.signal))
+ .rejects.toMatchObject({ name: TraceDecayAbortError.name, cause: null });
+ });
+
+ it("keeps recent-list completeness metadata through the bridge, tool, and text view", async () => {
+ const response = listing({
+ projects: Array.from({ length: 10 }, (_, index) => ({ ...PROJECT, project_id: `proj_${index}` })),
+ truncated: true, summary: { project_count: 10, repo_count: 9, truncated: true },
+ });
+ const call = vi.spyOn(ServeSession.prototype, "callJson").mockResolvedValue(response);
+ const mcp = await client(bridge());
+ const result = await mcp.callTool({ name: "tracedecay_workspace", arguments: {} }) as CallToolResult;
+ const view = projectView(result);
+ expect(view.query).toBe("");
+ expect(ready(view)).toMatchObject({
+ limit: 10,
+ truncated: true,
+ summary: response.summary,
+ });
+ expect(ready(view).projects).toHaveLength(10);
+ expect(ready(view).projects[0]).toMatchObject({ project_id: "proj_0", project_root: PROJECT.project_root });
+ expect(call).toHaveBeenCalledWith("tracedecay_project_list", { limit: 10 }, expect.any(AbortSignal));
+ expect(viewText(view)).toMatch(/more|partial|truncated/iu);
+ });
+
+ it("searches the registry with a bounded request and returns exact project identities", async () => {
+ const response = listing({ query: "older-billing" });
+ const call = vi.spyOn(ServeSession.prototype, "callJson").mockResolvedValue(response);
+ const mcp = await client(bridge());
+ const result = await mcp.callTool({ name: "tracedecay_search_projects", arguments: { query: " older-billing " } }) as CallToolResult;
+ const view = projectView(result);
+ expect(view.query).toBe("older-billing");
+ expect(ready(view).projects).toEqual([{
+ project_id: PROJECT.project_id, label: PROJECT.label, project_root: PROJECT.project_root,
+ head_branch: "main", default_branch: "main",
+ }]);
+ expect(call).toHaveBeenCalledExactlyOnceWith("tracedecay_project_search", { query: "older-billing", limit: 10 }, expect.any(AbortSignal));
+ expect(viewText(view)).toContain("older-billing");
+ const advertised = (await mcp.listTools()).tools.find((tool) => tool.name === "tracedecay_search_projects");
+ expect(advertised?._meta).toMatchObject({ ui: { visibility: ["app"] } });
+ expect(advertised?.annotations?.readOnlyHint).toBe(true);
+ });
+
+ it("clearing a query returns the bounded recent list", async () => {
+ const call = vi.spyOn(ServeSession.prototype, "callJson").mockResolvedValue(listing());
+ const mcp = await client(bridge());
+ const view = projectView(await mcp.callTool({ name: "tracedecay_search_projects", arguments: { query: " " } }) as CallToolResult);
+ expect(view.query).toBe("");
+ expect(call).toHaveBeenCalledExactlyOnceWith("tracedecay_project_list", { limit: 10 }, expect.any(AbortSignal));
+ });
+
+ it("distinguishes an empty registry from no search matches", async () => {
+ vi.spyOn(ServeSession.prototype, "callJson").mockResolvedValue(listing({
+ projects: [], summary: { project_count: 0, repo_count: 0, truncated: false },
+ }));
+ const via = bridge();
+ const recent = await projectsView(via);
+ const searched = await projectsView(via, undefined, "missing-project");
+ expect(recent).toMatchObject({ page: "projects", query: "", projects: { state: "empty" } });
+ expect(searched).toMatchObject({ page: "projects", query: "missing-project", projects: { state: "empty" } });
+ expect(viewText(recent)).toMatch(/No projects are registered/u);
+ expect(viewText(searched)).toMatch(/No .*match/iu);
+ expect(viewText(searched)).not.toContain("tracedecay init");
+ });
+
+ it("retains truncated search metadata and says the matches are partial", async () => {
+ const response = listing({
+ projects: Array.from({ length: 10 }, (_, index) => ({ ...PROJECT, project_id: `proj_${index}` })),
+ query: "billing", truncated: true, summary: { project_count: 10, repo_count: 8, truncated: true },
+ });
+ vi.spyOn(ServeSession.prototype, "callJson").mockResolvedValue(response);
+ const view = await projectsView(bridge(), undefined, "billing");
+ if (view.page !== "projects") throw new Error("expected projects");
+ expect(ready(view)).toMatchObject({ limit: 10, truncated: true, summary: response.summary });
+ expect(viewText(view)).toMatch(/more|partial|truncated/iu);
+ });
+
+ it("keeps registry failure distinct from an empty search", async () => {
+ const failure = { kind: "unavailable" as const, code: "registry_unavailable", message: "Registry is unavailable" };
+ vi.spyOn(ServeSession.prototype, "callJson").mockRejectedValue(new DaemonFailure(failure));
+ const view = await projectsView(bridge(), undefined, "billing");
+ expect(view).toMatchObject({ page: "projects", query: "billing", projects: { state: "failed", failure } });
+ expect(viewText(view)).toContain(failure.message);
+ expect(viewText(view)).not.toMatch(/No .*match/iu);
+ });
+
+ it("does not dispatch a search when profile authority denies admission", async () => {
+ const via = bridge();
+ const failure = { kind: "disconnected" as const, code: "daemon_authority_absent", message: "No daemon authority exists" };
+ vi.spyOn(via, "authority").mockResolvedValue({ kind: "absent", failure });
+ const call = vi.spyOn(ServeSession.prototype, "callJson");
+ await expect(via.searchProjects("billing")).rejects.toMatchObject({ failure });
+ expect(call).not.toHaveBeenCalled();
+ });
+
+ it("forwards host cancellation to the pending registry request", async () => {
+ let resolveEntered!: () => void;
+ const entered = new Promise((resolve) => { resolveEntered = resolve; });
+ let registrySignal: AbortSignal | undefined;
+ const call = vi.spyOn(ServeSession.prototype, "callJson").mockImplementation(async (_name, _request, signal) => {
+ registrySignal = signal;
+ return await new Promise((_resolve, reject) => {
+ if (signal === undefined) throw new Error("registry request lost its cancellation signal");
+ signal.addEventListener("abort", () => reject(signal.reason), { once: true });
+ resolveEntered();
+ });
+ });
+ const mcp = await client(bridge());
+ const controller = new AbortController();
+ const request = mcp.callTool({ name: "tracedecay_search_projects", arguments: { query: "older" } }, { signal: controller.signal });
+ // Observe rejection before abort so an immediately delivered cancellation
+ // cannot become an unhandled rejection while the test checks downstream.
+ const outcome = Promise.allSettled([request]);
+ await Promise.race([
+ entered,
+ request.then(() => { throw new Error("search completed without entering the registry request"); }),
+ ]);
+ controller.abort(new Error("superseded by another project query"));
+ expect(await outcome).toMatchObject([{ status: "rejected" }]);
+ await expect.poll(() => registrySignal?.aborted).toBe(true);
+ expect(call).toHaveBeenCalledExactlyOnceWith("tracedecay_project_search", { query: "older", limit: 10 }, registrySignal);
+ });
+});
diff --git a/plugin/chatgpt-extension/test/project-picker.test.ts b/plugin/chatgpt-extension/test/project-picker.test.ts
new file mode 100644
index 0000000000..37c65e1231
--- /dev/null
+++ b/plugin/chatgpt-extension/test/project-picker.test.ts
@@ -0,0 +1,305 @@
+// @vitest-environment jsdom
+import type { CallToolResult } from "@modelcontextprotocol/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { ProjectRef, ViewState } from "../src/shared/view.js";
+
+type HostApp = {
+ ontoolinput?: (params: { arguments?: Record }) => void;
+ ontoolresult?: (result: CallToolResult) => void;
+ onhostcontextchanged?: (context: Record) => void;
+ onteardown?: () => Promise;
+};
+
+const host = vi.hoisted(() => ({
+ app: null as HostApp | null,
+ callServerTool: vi.fn<(params: { name: string; arguments: Record }, options?: { signal?: AbortSignal }) => Promise>(),
+ updateModelContext: vi.fn(async (_context: unknown) => ({})),
+}));
+
+// Only the host boundary is replaced. Every DOM node, navigation decision,
+// cancellation signal and result-adoption guard comes from the shipped app.
+vi.mock("@modelcontextprotocol/ext-apps", () => ({
+ App: class {
+ constructor() { host.app = this as HostApp; }
+ connect() { return Promise.resolve(); }
+ getHostContext() { return undefined; }
+ callServerTool = host.callServerTool;
+ updateModelContext = host.updateModelContext;
+ },
+}));
+
+function deferred() {
+ let resolve!: (value: T) => void;
+ let reject!: (reason: unknown) => void;
+ const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
+ return { promise, resolve, reject };
+}
+
+type Request = ReturnType> & {
+ readonly name: string;
+ readonly args: Record;
+ readonly signal: AbortSignal;
+};
+
+const requests: Request[] = [];
+const trackListeners = () => vi.spyOn(window, "addEventListener");
+let addedListeners: ReturnType;
+
+beforeEach(async () => {
+ vi.resetModules();
+ requests.length = 0;
+ host.app = null;
+ host.updateModelContext.mockClear();
+ host.callServerTool.mockReset().mockImplementation((params, options) => {
+ if (options?.signal === undefined) throw new Error("app tool request did not carry cancellation");
+ const response = deferred();
+ requests.push({ ...response, name: params.name, args: params.arguments, signal: options.signal });
+ return response.promise;
+ });
+ document.body.innerHTML = '';
+ addedListeners = trackListeners();
+ await import("../src/app/main.js");
+ expect(host.app).not.toBeNull();
+});
+
+afterEach(async () => {
+ await host.app?.onteardown?.();
+ for (const [type, listener, options] of addedListeners.mock.calls) {
+ window.removeEventListener(type, listener, options);
+ }
+ addedListeners.mockRestore();
+ document.body.replaceChildren();
+});
+
+function project(index: number): ProjectRef {
+ return {
+ project_id: `proj_${index}`,
+ label: `project-${index}`,
+ project_root: `/repos/project-${index}`,
+ head_branch: "feature",
+ default_branch: "main",
+ };
+}
+
+function projectsView(projects: readonly ProjectRef[], query = "", truncated = false): Extract {
+ return {
+ page: "projects",
+ query,
+ daemon: { state: "connected", profile_root: "/isolated/profile", version: "test", pid: 123 },
+ projects: {
+ state: "ready",
+ data: {
+ projects,
+ limit: 10,
+ truncated,
+ summary: { project_count: projects.length, repo_count: projects.length, truncated },
+ },
+ },
+ };
+}
+
+function toolResult(view: ViewState): CallToolResult {
+ return { content: [], structuredContent: view };
+}
+
+function pushView(view: ViewState): void {
+ host.app!.ontoolresult!(toolResult(view));
+}
+
+function element(testId: string): T {
+ const node = document.querySelector(`[data-testid="${testId}"]`);
+ if (node === null) throw new Error(`missing rendered element: ${testId}`);
+ return node;
+}
+
+function submitProjectQuery(query: string, keyboard = false): Request {
+ const input = element("project-search-input");
+ input.value = query;
+ if (keyboard) {
+ input.focus();
+ input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
+ } else {
+ element("project-search-submit").click();
+ }
+ const request = requests.at(-1);
+ if (request === undefined) throw new Error("project search did not dispatch a request");
+ return request;
+}
+
+async function deliver(request: Request, view: ViewState): Promise {
+ request.resolve(toolResult(view));
+ // The app attached its continuation before this test awaits the same promise.
+ await request.promise;
+}
+
+function expectSelected(projectId: string): void {
+ expect(document.getElementById("root")?.dataset["page"]).toBe("search");
+ const latest = host.updateModelContext.mock.calls.at(-1)?.[0];
+ expect(latest).toMatchObject({ structuredContent: { page: "search", project_id: projectId } });
+}
+
+describe("project picker DOM", () => {
+ it("labels the bounded recent list as partial without presenting its page count as a registry total", () => {
+ pushView(projectsView(Array.from({ length: 10 }, (_, index) => project(index)), "", true));
+ expect(document.querySelectorAll('[data-testid="project-row"]')).toHaveLength(10);
+ expect(element("projects-panel").textContent).toContain("Recent registered projects");
+ expect(element("projects-completeness").textContent).toContain("10 project(s) shown");
+ expect(element("projects-completeness").textContent).toContain("outside this recent list");
+ expect(element("projects-completeness").textContent).not.toContain("All registered projects");
+ expect(element("project-search-input").getAttribute("aria-label")).toBe("Find registered projects");
+ });
+
+ it("finds a project outside the recent list and selects its exact identity before searching its code", async () => {
+ const recent = project(0);
+ const older = { ...project(11), label: recent.label };
+ pushView(projectsView([recent], "", true));
+ const request = submitProjectQuery(` ${older.project_root} `, true);
+ expect(request).toMatchObject({ name: "tracedecay_search_projects", args: { query: older.project_root } });
+ expect(element("project-search-input").value).toBe(older.project_root);
+ expect(document.activeElement).toBe(element("project-search-input"));
+ await deliver(request, projectsView([older], older.project_root));
+ expect(element("projects-panel").textContent).toContain(`Projects matching "${older.project_root}"`);
+ expect(element("project-open").getAttribute("data-project-id")).toBe(older.project_id);
+ element("project-open").click();
+ expectSelected(older.project_id);
+ element("search-input").value = "invoice";
+ element("search-submit").click();
+ expect(requests.at(-1)).toMatchObject({ name: "tracedecay_search_code", args: { project_id: older.project_id, query: "invoice" } });
+ });
+
+ it("renders empty registration, zero matches and unavailable registry as distinct states", () => {
+ const recent = projectsView([]);
+ pushView({ ...recent, projects: { state: "empty", message: "No projects are registered in this TraceDecay profile." } });
+ expect(element("projects-empty").textContent).toContain("No projects are registered");
+ expect(element("projects-panel").textContent).toContain("Recent registered projects");
+
+ pushView({ ...recent, query: "missing", projects: { state: "empty", message: 'No registered projects match "missing".' } });
+ expect(element("projects-empty").textContent).toContain('No registered projects match "missing"');
+ expect(element("project-search-input").value).toBe("missing");
+
+ pushView({ ...recent, query: "missing", projects: { state: "failed", failure: { kind: "unavailable", code: "registry_unavailable", message: "Registry could not be read" } } });
+ expect(element("projects-failed-kind").textContent).toBe("unavailable");
+ expect(element("projects-failed").textContent).toContain("registry_unavailable");
+ expect(document.querySelector('[data-testid="projects-empty"]')).toBeNull();
+ });
+
+ it("labels truncated search matches and lets the user return to the recent listing", async () => {
+ pushView(projectsView([project(1)], "project", true));
+ expect(element("projects-completeness").textContent).toContain("More matches are available; refine your search");
+ element("project-search-recent").click();
+ const request = requests.at(-1)!;
+ expect(request).toMatchObject({ name: "tracedecay_search_projects", args: { query: "" } });
+ await deliver(request, projectsView([project(0)]));
+ expect(element("project-search-input").value).toBe("");
+ expect(element("projects-panel").textContent).toContain("Recent registered projects");
+ });
+
+ it("keeps the query editable while pending, aborts the replaced call and ignores its late success", async () => {
+ pushView(projectsView([project(0)]));
+ const first = submitProjectQuery("older");
+ expect(element("loading").textContent).toContain("Searching projects");
+ expect(element("project-search-submit").disabled).toBe(false);
+ const second = submitProjectQuery("shipping", true);
+ expect(requests).toHaveLength(2);
+ expect(first.signal.aborted).toBe(true);
+ expect(second.signal.aborted).toBe(false);
+ await deliver(first, projectsView([project(11)], "older"));
+ expect(element("project-search-input").value).toBe("shipping");
+ expect(element("loading").textContent).toContain('"shipping"');
+ await deliver(second, projectsView([project(12)], "shipping"));
+ expect(element("project-open").getAttribute("data-project-id")).toBe("proj_12");
+ expect(document.querySelector('[data-testid="loading"]')).toBeNull();
+ });
+
+ it("keeps focus and the complete draft when typing successive characters over a pending query", async () => {
+ pushView(projectsView([project(0)]));
+ const first = submitProjectQuery("older");
+ const originalInput = element("project-search-input");
+ originalInput.focus();
+ originalInput.setSelectionRange(0, originalInput.value.length);
+ let typed = "";
+ for (const character of "shipping") {
+ const focused = document.activeElement;
+ expect(focused).toBeInstanceOf(HTMLInputElement);
+ const input = focused as HTMLInputElement;
+ const start = input.selectionStart!;
+ const end = input.selectionEnd!;
+ input.value = `${input.value.slice(0, start)}${character}${input.value.slice(end)}`;
+ input.setSelectionRange(start + 1, start + 1);
+ input.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: character }));
+ typed += character;
+ expect(document.activeElement).toBe(element("project-search-input"));
+ expect(element("project-search-input").value).toBe(typed);
+ expect(element("project-search-input").selectionStart).toBe(typed.length);
+ }
+ expect(first.signal.aborted).toBe(true);
+ expect(requests).toHaveLength(1);
+ element("project-search-input").dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
+ const second = requests.at(-1)!;
+ expect(second.args).toEqual({ query: "shipping" });
+ await deliver(first, projectsView([project(11)], "older"));
+ expect(element("project-search-input").value).toBe("shipping");
+ await deliver(second, projectsView([project(12)], "shipping"));
+ expect(element("project-open").getAttribute("data-project-id")).toBe("proj_12");
+ });
+
+ it("does not replace newer results with a superseded request failure", async () => {
+ pushView(projectsView([project(0)]));
+ const first = submitProjectQuery("older");
+ const second = submitProjectQuery("shipping");
+ await deliver(second, projectsView([project(12)], "shipping"));
+ const rendered = document.getElementById("root")!.innerHTML;
+ first.reject(new Error("late registry failure"));
+ await expect(first.promise).rejects.toThrow("late registry failure");
+ expect(first.signal.aborted).toBe(true);
+ expect(document.getElementById("root")!.innerHTML).toBe(rendered);
+ expect(document.querySelector('[data-testid="host-error"]')).toBeNull();
+ });
+
+ it.each(["success", "failure"] as const)("does not replace an explicitly selected project with a pending search's late %s", async (outcome) => {
+ pushView(projectsView([project(0)]));
+ const request = submitProjectQuery("older");
+ element("project-open").click();
+ expectSelected("proj_0");
+ expect(request.signal.aborted).toBe(true);
+ if (outcome === "success") await deliver(request, projectsView([project(11)], "older"));
+ else {
+ request.reject(new Error("late failure after project selection"));
+ await expect(request.promise).rejects.toThrow("late failure after project selection");
+ }
+ expectSelected("proj_0");
+ expect(element("crumb-project").textContent).toBe("project-0");
+ expect(document.querySelector('[data-testid="host-error"]')).toBeNull();
+ });
+
+ it("invalidates a pending project search when the host deep-links to a registered project", async () => {
+ pushView(projectsView([project(0)]));
+ const request = submitProjectQuery("older");
+ host.app!.onhostcontextchanged!({ "openai/deepLink": "/projects/proj_0" });
+ expect(request.signal.aborted).toBe(true);
+ expectSelected("proj_0");
+ await deliver(request, projectsView([project(11)], "older"));
+ expectSelected("proj_0");
+ });
+
+ it("invalidates pending app requests when an external host tool result arrives", async () => {
+ pushView(projectsView([project(0)]));
+ const request = submitProjectQuery("older");
+ pushView(projectsView([project(12)], "shipping"));
+ expect(request.signal.aborted).toBe(true);
+ await deliver(request, projectsView([project(11)], "older"));
+ expect(element("project-search-input").value).toBe("shipping");
+ expect(element("project-open").getAttribute("data-project-id")).toBe("proj_12");
+ });
+
+ it.each(["teardown", "pagehide"] as const)("aborts a pending app request on %s and ignores its late result", async (event) => {
+ pushView(projectsView([project(0)]));
+ const request = submitProjectQuery("older");
+ if (event === "teardown") await host.app!.onteardown!();
+ else window.dispatchEvent(new Event("pagehide"));
+ expect(request.signal.aborted).toBe(true);
+ const rendered = document.getElementById("root")!.innerHTML;
+ await deliver(request, projectsView([project(11)], "older"));
+ expect(document.getElementById("root")!.innerHTML).toBe(rendered);
+ });
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b9866b5fc4..526a4b9252 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -318,6 +318,9 @@ importers:
'@types/node':
specifier: ^26.6.5
version: 26.6.5
+ jsdom:
+ specifier: ^30.0.0
+ version: 30.1.3
typescript:
specifier: ^7.0.0
version: 7.0.2
From d857a71595f47c1a5e28fc5b61560548836d9e3d Mon Sep 17 00:00:00 2001
From: Zack Jackson
Date: Fri, 23 Oct 2026 21:21:36 -0700
Subject: [PATCH 2/4] fix(extension): preserve project picker button focus
---
plugin/chatgpt-extension/src/app/main.ts | 9 +++++----
.../test/project-picker.test.ts | 20 +++++++++++++++++++
2 files changed, 25 insertions(+), 4 deletions(-)
diff --git a/plugin/chatgpt-extension/src/app/main.ts b/plugin/chatgpt-extension/src/app/main.ts
index 50564e039f..f293102d52 100644
--- a/plugin/chatgpt-extension/src/app/main.ts
+++ b/plugin/chatgpt-extension/src/app/main.ts
@@ -573,15 +573,16 @@ function renderBody(): Node[] {
function render(): void {
const focused = document.activeElement;
+ const projectControl = focused instanceof HTMLElement && focused.closest('[data-testid="project-search-form"]') !== null ? focused.dataset["testid"] : undefined;
const projectInput = focused instanceof HTMLInputElement && focused.dataset["testid"] === "project-search-input" ? focused : null;
- const selection = projectInput === null ? null : [projectInput.selectionStart, projectInput.selectionEnd] as const;
+ const selection = projectInput === null ? null : [projectInput.selectionStart, projectInput.selectionEnd, projectInput.selectionDirection] as const;
mount.replaceChildren(renderHeader(currentView()), ...renderBody());
mount.dataset["phase"] = screen.phase;
mount.dataset["page"] = currentView()?.page ?? "";
- if (selection !== null) {
- const replacement = mount.querySelector('[data-testid="project-search-input"]');
+ if (projectControl !== undefined) {
+ const replacement = mount.querySelector(`[data-testid="${projectControl}"]`);
replacement?.focus({ preventScroll: true });
- replacement?.setSelectionRange(...selection);
+ if (replacement instanceof HTMLInputElement && selection !== null) replacement.setSelectionRange(...selection);
}
}
diff --git a/plugin/chatgpt-extension/test/project-picker.test.ts b/plugin/chatgpt-extension/test/project-picker.test.ts
index 37c65e1231..d5b7d8ae58 100644
--- a/plugin/chatgpt-extension/test/project-picker.test.ts
+++ b/plugin/chatgpt-extension/test/project-picker.test.ts
@@ -211,6 +211,26 @@ describe("project picker DOM", () => {
expect(document.querySelector('[data-testid="loading"]')).toBeNull();
});
+ it.each(["project-search-submit", "project-search-recent"])("keeps keyboard focus on %s through loading and results", async (testId) => {
+ pushView(projectsView([project(0)], "older"));
+ const control = element(testId);
+ // jsdom does not perform native keyboard activation. Focus the tab stop
+ // and dispatch the click that Enter/Space generates on a button.
+ control.focus();
+ control.click();
+ const request = requests.at(-1)!;
+ const query = testId === "project-search-submit" ? "older" : "";
+ expect(request).toMatchObject({ name: "tracedecay_search_projects", args: { query } });
+ expect(control.isConnected).toBe(false);
+ expect(document.activeElement).toBe(element(testId));
+ await deliver(request, projectsView([project(11)], query));
+ expect(document.activeElement).toBe(element(testId));
+ // The restored control remains usable for the next keyboard activation.
+ element(testId).click();
+ expect(requests).toHaveLength(2);
+ expect(document.activeElement).toBe(element(testId));
+ });
+
it("keeps focus and the complete draft when typing successive characters over a pending query", async () => {
pushView(projectsView([project(0)]));
const first = submitProjectQuery("older");
From 5003a2b64fb86e4f8c0cbd6fc9b3a34231a2457b Mon Sep 17 00:00:00 2001
From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com>
Date: Fri, 23 Oct 2026 21:23:47 -0700
Subject: [PATCH 3/4] fix(extension): preserve runfile paths in DOM tests
---
plugin/chatgpt-extension/vitest.config.ts | 2 ++
1 file changed, 2 insertions(+)
diff --git a/plugin/chatgpt-extension/vitest.config.ts b/plugin/chatgpt-extension/vitest.config.ts
index cc39bc3768..50bcf770c0 100644
--- a/plugin/chatgpt-extension/vitest.config.ts
+++ b/plugin/chatgpt-extension/vitest.config.ts
@@ -1,6 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
+ // Keep DOM test imports inside Bazel's declared runfiles tree.
+ resolve: { preserveSymlinks: true },
test: {
include: ["test/**/*.test.ts"],
testTimeout: 180_000,
From 005f01e4ea242275cf2fd909233c2f40e140d893 Mon Sep 17 00:00:00 2001
From: ScriptedAlchemy
Date: Sat, 24 Oct 2026 23:30:15 +0000
Subject: [PATCH 4/4] build(plugin): regenerate chatgpt embed from built
TypeScript SDK
---
plugin/chatgpt-extension/embedded/app.html | 14 +++++++-------
plugin/chatgpt-extension/embedded/server.mjs | 20 ++++++++++----------
2 files changed, 17 insertions(+), 17 deletions(-)
diff --git a/plugin/chatgpt-extension/embedded/app.html b/plugin/chatgpt-extension/embedded/app.html
index c2068036cf..cdbc1fa281 100644
--- a/plugin/chatgpt-extension/embedded/app.html
+++ b/plugin/chatgpt-extension/embedded/app.html
@@ -4,15 +4,15 @@
TraceDecay Code Explorer
-
+
-
+container holding the app. Specify either width or maxWidth, and either height or maxHeight.`),locale:rp().optional().describe("User's language and region preference in BCP 47 format."),timeZone:rp().optional().describe("User's timezone in IANA format."),userAgent:rp().optional().describe("Host application identifier."),platform:r9([ii("web"),ii("desktop"),ii("mobile")]).optional().describe("Platform type for responsive design decisions."),deviceCapabilities:r1({touch:rU().optional().describe("Whether the device supports touch input."),hover:rU().optional().describe("Whether the device supports hover interactions.")}).optional().describe("Device input capabilities."),safeAreaInsets:r1({top:rN().describe("Top safe area inset in pixels."),right:rN().describe("Right safe area inset in pixels."),bottom:rN().describe("Bottom safe area inset in pixels."),left:rN().describe("Left safe area inset in pixels.")}).optional().describe("Mobile safe area boundaries in pixels.")}).passthrough(),dk=r1({method:ii("ui/notifications/host-context-changed"),params:dw.describe("Partial context update containing only changed fields.")}),dR=(r1({method:ii("ui/update-model-context"),params:r1({content:rQ(u3).optional().describe("Context content blocks (text, image, etc.)."),structuredContent:ie(rp(),rV().describe("Structured content for machine-readable context data.")).optional().describe("Structured content for machine-readable context data.")})}),r1({method:ii("ui/initialize"),params:r1({appInfo:un.describe("App identification (name and version)."),appCapabilities:dv.describe("Features and capabilities this app provides."),protocolVersion:rp().describe("Protocol version this app supports.")})}),r1({protocolVersion:rp().describe('Negotiated protocol version string (e.g., "2025-11-21").'),hostInfo:un.describe("Host application identification and version."),hostCapabilities:dy.describe("Features and capabilities provided by the host."),hostContext:dw.describe("Rich context about the host environment.")}).passthrough()),dx={target:"draft-2020-12"};async function dE(e,t){let o=e["~standard"];if(o.jsonSchema)return o.jsonSchema[t](dx);throw Error(`Schema (vendor: ${o.vendor}) does not implement Standard JSON Schema (~standard.jsonSchema). Use a library that does (zod v4, ArkType, Valibot) or wrap your schema accordingly.`)}async function dT(e,t,o=""){let n=await e["~standard"].validate(t);if(n.issues)throw Error(o+n.issues.map(e=>{let t=e.path?.map(e=>"object"==typeof e?e.key:e).join(".");return t?`${t}: ${e.message}`:e.message}).join("; "));return n.value}class dP extends cq{_appInfo;_appCapabilities;options;_hostCapabilities;_hostInfo;_hostContext;_registeredTools={};_initializedSent=!1;_registeredEvents=new Set;_events=new p5;_methods=new p6;setRequestHandler=(...e)=>{let[t,...o]=p3("request",e)??e;this._methods.claim(t,"setRequestHandler"),super.setRequestHandler.call(this,t,...o)};setNotificationHandler=(...e)=>{let[t,...o]=p3("notification",e)??e;this._methods.claim(t,"setNotificationHandler"),super.setNotificationHandler.call(this,t,...o)};removeRequestHandler=e=>{this._methods.release(e),super.removeRequestHandler(e)};removeNotificationHandler=e=>{this._methods.release(e),super.removeNotificationHandler(e)};replaceRequestHandler=(e,...t)=>{this._methods.replace(e),super.setRequestHandler.call(this,e,...t)};_assertInitialized(e){if(this._initializedSent)return;let t=`[ext-apps] App.${e}() called before connect() completed the ui/initialize handshake. Await app.connect() before calling this method, or move data loading to an ontoolresult handler.`;if(this.options?.strict)throw Error(t);console.warn(`${t}. This will throw in a future release.`)}eventSchemas={toolinput:dc,toolinputpartial:du,toolresult:dS,toolcancelled:dp,hostcontextchanged:dk};static ONE_SHOT_EVENTS=new Set(["toolinput","toolinputpartial","toolresult","toolcancelled"]);_everHadListener=new Set;_assertHandlerTiming(e){if(!dP.ONE_SHOT_EVENTS.has(e)||this._everHadListener.has(e)||(this._everHadListener.add(e),!this._initializedSent))return;let t=`[ext-apps] "${String(e)}" handler registered after connect() completed the ui/initialize handshake. The host may have already sent this notification. Register handlers before calling app.connect().`;if(this.options?.strict)throw Error(t);console.warn(t)}setEventHandler(e,t){t&&this._assertHandlerTiming(e),this._ensureEventSlot(e),this._events.setHandler(e,t)}addEventListener(e,t){this._assertHandlerTiming(e),this._ensureEventSlot(e),this._events.addEventListener(e,t)}removeEventListener(e,t){this._events.removeEventListener(e,t)}_ensureEventSlot(e){if(this._registeredEvents.has(e))return;let t=this.eventSchemas[e];if(!t)throw Error(`Unknown event: ${String(e)}`);this.setNotificationHandler(t.shape.method.value,{params:t.shape.params},t=>{"hostcontextchanged"===e&&(this._hostContext={...this._hostContext,...t}),this._events.dispatch(e,t)}),this._registeredEvents.add(e)}getEventHandler(e){return this._events.getHandler(e)}warnIfRequestHandlerReplaced(e,t,o){t&&o&&console.warn(`[MCP Apps] ${e} handler replaced. Previous handler will no longer be called.`)}constructor(e,t={},o={autoResize:!0}){super(o),this._appInfo=e,this._appCapabilities=t,this.options=o,this._methods.replace("notifications/cancelled"),this._methods.replace("notifications/progress"),o.allowUnsafeEval||ew({jitless:!0}),this.replaceRequestHandler("ping",e=>(console.log("Received ping:",e.params),{})),this.setEventHandler("hostcontextchanged",void 0)}buildContext(e){return e}registerAppCapabilities(e){var t;let o;if(this.transport)throw Error("Cannot register capabilities after transport is established");this._appCapabilities=(t=this._appCapabilities,o={...t,...e},void 0!==e.experimental&&(o.experimental={...t.experimental,...e.experimental}),void 0!==e.tools&&(o.tools={...t.tools,...e.tools}),o)}registerTool(e,t,o){if(this._registeredTools[e])throw Error(`Tool ${e} is already registered`);let n=this,r=()=>{n._initializedSent&&n._appCapabilities.tools?.listChanged&&n.sendToolListChanged()},i=void 0!==t.inputSchema,a={title:t.title,description:t.description,inputSchema:t.inputSchema,outputSchema:t.outputSchema,annotations:t.annotations,_meta:t._meta,enabled:!0,enable(){this.enabled=!0,r()},disable(){this.enabled=!1,r()},update(e){Object.assign(this,e),r()},remove(){n._registeredTools[e]===a&&(delete n._registeredTools[e],r())},handler:async(t,n)=>{let r;if(!a.enabled)throw Error(`Tool ${e} is disabled`);if(i){let i=a.inputSchema,s=i?await dT(i,t??{},`Invalid input for tool ${e}: `):t??{};r=await o(s,n)}else r=await o(n);return a.outputSchema&&!r.isError&&(r.structuredContent=await dT(a.outputSchema,r.structuredContent,`Invalid output for tool ${e}: `)),r}};return this._registeredTools[e]=a,this._appCapabilities.tools||this.transport||this.registerAppCapabilities({tools:{listChanged:!0}}),this.ensureToolHandlersInitialized(),r(),a}_toolHandlersInitialized=!1;ensureToolHandlersInitialized(){this._toolHandlersInitialized||(this._toolHandlersInitialized=!0,this.oncalltool=async(e,t)=>{let o=this._registeredTools[e.name];if(!o)throw Error(`Tool ${e.name} not found`);return o.handler(e.arguments,t)},this.onlisttools=async(e,t)=>({tools:await Promise.all(Object.entries(this._registeredTools).filter(([e,t])=>t.enabled).map(async([e,t])=>{let o={name:e,title:t.title,description:t.description,inputSchema:t.inputSchema?await dE(t.inputSchema,"input"):{type:"object",properties:{}}};return t.outputSchema&&(o.outputSchema=await dE(t.outputSchema,"output")),t.annotations&&(o.annotations=t.annotations),t._meta&&(o._meta=t._meta),o}))}))}async sendToolListChanged(e={}){this._assertInitialized("sendToolListChanged"),await this.notification({method:"notifications/tools/list_changed",params:e})}getHostCapabilities(){return this._hostCapabilities}getHostVersion(){return this._hostInfo}getHostContext(){return this._hostContext}get ontoolinput(){return this.getEventHandler("toolinput")}set ontoolinput(e){this.setEventHandler("toolinput",e)}get ontoolinputpartial(){return this.getEventHandler("toolinputpartial")}set ontoolinputpartial(e){this.setEventHandler("toolinputpartial",e)}get ontoolresult(){return this.getEventHandler("toolresult")}set ontoolresult(e){this.setEventHandler("toolresult",e)}get ontoolcancelled(){return this.getEventHandler("toolcancelled")}set ontoolcancelled(e){this.setEventHandler("toolcancelled",e)}get onhostcontextchanged(){return this.getEventHandler("hostcontextchanged")}set onhostcontextchanged(e){this.setEventHandler("hostcontextchanged",e)}_onteardown;get onteardown(){return this._onteardown}set onteardown(e){this.warnIfRequestHandlerReplaced("onteardown",this._onteardown,e),this._onteardown=e,this.replaceRequestHandler("ui/resource-teardown",{params:dm.shape.params,result:df},(e,t)=>{if(!this._onteardown)throw Error("No onteardown handler set");return this._onteardown(e,t)})}_oncalltool;get oncalltool(){return this._oncalltool}set oncalltool(e){this.warnIfRequestHandlerReplaced("oncalltool",this._oncalltool,e),this._oncalltool=e,this.replaceRequestHandler("tools/call",(e,t)=>{if(!this._oncalltool)throw Error("No oncalltool handler set");return this._oncalltool(e.params,t)})}_onlisttools;get onlisttools(){return this._onlisttools}set onlisttools(e){this.warnIfRequestHandlerReplaced("onlisttools",this._onlisttools,e),this._onlisttools=e,this.replaceRequestHandler("tools/list",(e,t)=>{if(!this._onlisttools)throw Error("No onlisttools handler set");return this._onlisttools(e.params,t)})}assertCapabilityForMethod(e){if("sampling/createMessage"===e&&!this._hostCapabilities?.sampling)throw Error(`Host does not support sampling (required for ${e})`)}assertRequestHandlerCapability(e){switch(e){case"tools/call":case"tools/list":if(!this._appCapabilities.tools)throw Error(`Client does not support tool capability (required for ${e})`);return;default:return}}assertNotificationCapability(e){}async callServerTool(e,t){if(this._assertInitialized("callServerTool"),"string"==typeof e)throw Error(`callServerTool() expects an object as its first argument, but received a string ("${e}"). Did you mean: callServerTool({ name: "${e}", arguments: { ... } })?`);return await this.request({method:"tools/call",params:e},{onprogress:()=>{},resetTimeoutOnProgress:!0,...t})}async readServerResource(e,t){return this._assertInitialized("readServerResource"),await this.request({method:"resources/read",params:e},t)}async listServerResources(e,t){return this._assertInitialized("listServerResources"),await this.request({method:"resources/list",params:e},t)}async createSamplingMessage(e,t){return this._assertInitialized("createSamplingMessage"),await this.request({method:"sampling/createMessage",params:e},t)}sendMessage(e,t){return this._assertInitialized("sendMessage"),this.request({method:"ui/message",params:e},da,t)}sendLog(e){return this.notification({method:"notifications/message",params:e})}updateModelContext(e,t){return this._assertInitialized("updateModelContext"),this.request({method:"ui/update-model-context",params:e},c8,t)}openLink(e,t){return this._assertInitialized("openLink"),this.request({method:"ui/open-link",params:e},dr,t)}sendOpenLink=this.openLink;downloadFile(e,t){return this._assertInitialized("downloadFile"),this.request({method:"ui/download-file",params:e},di,t)}requestTeardown(e={}){return this.notification({method:"ui/notifications/request-teardown",params:e})}requestDisplayMode(e,t){return this._assertInitialized("requestDisplayMode"),this.request({method:"ui/request-display-mode",params:e},d_,t)}sendSizeChanged(e){return this.notification({method:"ui/notifications/size-changed",params:e})}setupSizeChangedNotifications(){let e=!1,t=0,o=0,n=()=>{e||(e=!0,requestAnimationFrame(()=>{e=!1;let n=document.documentElement,r=n.style.height;n.style.height="max-content";let i=Math.ceil(n.getBoundingClientRect().height);n.style.height=r;let a=Math.ceil(window.innerWidth);(a!==t||i!==o)&&(t=a,o=i,this.sendSizeChanged({width:a,height:i}))}))};n();let r=new ResizeObserver(n);return r.observe(document.documentElement),r.observe(document.body),()=>r.disconnect()}async connect(e=new p7(window.parent,window.parent),t){if(this.transport)throw Error("App is already connected. Call close() before connecting again.");this._initializedSent=!1;try{await super.connect(e);let o=await this.request({method:"ui/initialize",params:{appCapabilities:this._appCapabilities,appInfo:this._appInfo,protocolVersion:"2026-01-26"}},dR,t);if(void 0===o)throw Error(`Server sent invalid initialize result: ${o}`);this._hostCapabilities=o.hostCapabilities,this._hostInfo=o.hostInfo,this._hostContext=o.hostContext,await this.notification({method:"ui/notifications/initialized"}),this._initializedSent=!0,this.options?.autoResize&&this.setupSizeChangedNotifications()}catch(e){throw this.close(),e}}}let dC=new Set(["projects","search","symbol","failure"]);function dI(e){switch(e.kind){case"projects":return"/";case"project":return`/projects/${encodeURIComponent(e.project_id)}`;case"search":return`/projects/${encodeURIComponent(e.project_id)}/search?q=${encodeURIComponent(e.query)}`;case"symbol":return`/projects/${encodeURIComponent(e.project_id)}/symbols/${encodeURIComponent(e.node_id)}`}}let dA=document.getElementById("root");if(null===dA)throw Error("app root element missing");let dO=new dP({name:"tracedecay-code-explorer",version:"0.0.0"},{availableDisplayModes:["inline","fullscreen"]}),d$={phase:"connecting"},dz=[],dq=null,dj=null,dN=null;function dM(){let e=dj;dj=null,e?.abort()}function dH(e){d$=e,ht()}function dL(){return"view"===d$.phase?d$.view:"loading"===d$.phase?d$.previous:null}function dU(e){dM(),dN="projects"===e.page?e.query:null,"projects"===e.page&&(dz="ready"===e.projects.state?e.projects.data.projects:[]),dH({phase:"view",view:e}),dD(e)}function dJ(e){var t;return"object"==typeof(t=e.structuredContent)&&null!==t&&"page"in t&&"string"==typeof t.page&&dC.has(t.page)?e.structuredContent:null}async function dW(e,t,o,n){dM();let r=new AbortController;dj=r,void 0!==n&&(dN=n),dH({phase:"loading",label:o,previous:dL(),projectQuery:n});try{let o=await dO.callServerTool({name:e,arguments:t},{signal:r.signal});if(dj!==r)return;dj=null;let n=dJ(o);if(null===n)return void dH({phase:"host_error",message:`${e} returned no view payload`});dU(n)}catch(e){if(dj!==r)return;dj=null,dH({phase:"host_error",message:e instanceof Error?e.message:String(e)})}}async function dD(e){let t=function(e){switch(e.page){case"projects":return null;case"search":var t;return{content:[{type:"text",text:`TraceDecay search in ${e.project.label} for "${e.query}": ${function(e,t){switch(e.state){case"ready":return t(e.data);case"empty":return e.message;case"failed":return`${e.failure.kind}: ${e.failure.message}`}}(e.results,e=>`${e.hits.length} hit(s), recall ${e.recall}${e.undisplayable.length>0?`, ${e.undisplayable.length} undisplayable`:""}`)}${null===(t=e.provenance)?"":` [reported project status: branch ${t.branch??"?"} commit ${t.commit??"?"} generation ${t.generation??"?"} freshness ${t.freshness.state}]`}`}],structuredContent:{page:"search",project_id:e.project.project_id,query:e.query,deep_link:dI({kind:"search",project_id:e.project.project_id,query:e.query}),provenance:e.provenance}};case"symbol":if(null===e.evidence)return null;return{content:[{type:"text",text:e.evidence.markdown}],structuredContent:e.evidence.structured};case"failure":return{content:[{type:"text",text:`TraceDecay could not answer (${e.failure.kind}/${e.failure.code}): ${e.failure.message}`}],structuredContent:{page:"failure",failure:e.failure}}}}(e);if(null!==t)try{await dO.updateModelContext(t)}catch{}}function dK(){dW("tracedecay_workspace",{},"Loading registered projects…","")}function dZ(e){let t=e.trim();dW("tracedecay_search_projects",{query:t},""===t?"Loading recent projects…":`Searching projects for "${t}"…`,t)}function dF(e){dU({page:"search",project:e,query:"",results:{state:"empty",message:"Type a symbol name or phrase to search this project."},provenance:null})}function dB(e,t){dW("tracedecay_search_code",{project_id:e,query:t},`Searching for "${t}"…`)}function dV(e,t){dW("tracedecay_inspect_symbol",{project_id:e,node_id:t},"Loading symbol…")}function dG(e){let t,o="string"==typeof(t=Reflect.get(e,"openai/deepLink"))?t:"object"==typeof t&&null!==t&&"url"in t&&"string"==typeof t.url?t.url:null;if(null===o||o===dq)return;dq=o;let n=function(e){let t;try{t=new URL(e,"https://tracedecay.invalid")}catch{return null}let o=t.pathname.split("/").filter(e=>e.length>0).map(decodeURIComponent);if(0===o.length)return{kind:"projects"};if("projects"!==o[0]||void 0===o[1])return null;let n=o[1];return 2===o.length?{kind:"project",project_id:n}:"search"===o[2]&&3===o.length?{kind:"search",project_id:n,query:t.searchParams.get("q")??""}:"symbols"===o[2]&&void 0!==o[3]&&4===o.length?{kind:"symbol",project_id:n,node_id:o[3]}:null}(o);null!==n&&function(e){switch(e.kind){case"projects":dK();return;case"project":{let t=dz.find(t=>t.project_id===e.project_id);void 0!==t?dF(t):dB(e.project_id,"");return}case"search":dB(e.project_id,e.query);return;case"symbol":dV(e.project_id,e.node_id);return}}(n)}function dX(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},o=arguments.length>2&&void 0!==arguments[2]?arguments[2]:[],n=document.createElement(e);for(let[e,o]of Object.entries(t))n.setAttribute(e,o);return n.append(...o),n}function dY(e,t){let o=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{},n=dX("button",{type:"button",...o},[e]);return n.addEventListener("click",t),n}function dQ(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"neutral",o=arguments.length>2?arguments[2]:void 0,n={class:"neutral"===t?"badge":`badge ${t}`};return void 0!==o&&(n["data-testid"]=o),dX("span",n,[e])}function d0(e){return"stale"===e.kind||"unavailable"===e.kind?"warn":"bad"}function d1(e){switch(e){case"current":case"fresh":return"ok";case"stale":case"possibly_stale":case"warming":case"restoring":return"warn";case"parked":case"unavailable":return"bad"}}function d2(e,t,o){return dX("p",{class:"neutral"===t?"notice":`notice ${t}`,"data-testid":o},[e])}function d4(e,t){let o=d2(`${e.message} (${e.code})`,d0(e),t);return o.prepend(dQ(e.kind,d0(e),`${t}-kind`)," "),o}function d9(e,t,o){switch(e.state){case"ready":return o(e.data);case"empty":return d2(e.message,"neutral",`${t}-empty`);case"failed":return d4(e.failure,`${t}-failed`)}}function d5(e,t,o){let n=arguments.length>3&&void 0!==arguments[3]?arguments[3]:[];return dX("section",{class:"panel","data-testid":t},[dX("h2",{},[e,...n]),...o])}function d6(e,t){return dY(t.qualified_name||t.name,()=>dV(e,t.node_id),{class:"mono","data-testid":"symbol-link","data-node-id":t.node_id})}function d8(e){let t=null===e.start_line?"":`:${e.start_line}`;return dX("span",{class:"loc"},[`${e.file}${t}`])}function d3(e,t){let o,n,r,i="connected"===e.daemon.state?dX("p",{class:"notice","data-testid":"daemon-state"},[dQ("connected","ok","daemon-badge"),` tracedecay ${e.daemon.version} (pid ${e.daemon.pid}) \xb7 profile `,dX("code",{},[e.daemon.profile_root])]):d4(e.daemon.failure,"daemon-state"),a=d9(e.projects,"projects",t=>{let o=dX("ul",{class:"list"},t.projects.map(e=>dX("li",{"data-testid":"project-row"},[dY(e.label,()=>dF(e),{class:"primary","data-testid":"project-open","data-project-id":e.project_id}),dX("span",{class:"loc"},[e.project_root]),null===e.head_branch?"":dQ(e.head_branch)]))),n=`${t.projects.length} project(s) shown (up to ${t.limit}).`;return dX("div",{},[d2(t.truncated?`${n} More ${""===e.query?"registered projects are available; search to find projects outside this recent list":"matches are available; refine your search to find a specific project"}.`:`${n} ${""===e.query?"All registered projects are shown":"All matching projects are shown"}.`,t.truncated?"warn":"neutral","projects-completeness"),o])}),s=d5(""===e.query?"Recent registered projects":`Projects matching "${e.query}"`,"projects-panel",[a]);return[i,(o=dX("input",{type:"search",name:"project-query",placeholder:"Find a registered project…",value:dN??t??e.query,"data-testid":"project-search-input","aria-label":"Find registered projects"}),n=()=>dZ(o.value),r=dX("form",{class:"search project-search","data-testid":"project-search-form",role:"search","aria-label":"Registered projects"},[o,dY("Find projects",n,{class:"primary","data-testid":"project-search-submit"}),dY("Recent projects",()=>dZ(""),{"data-testid":"project-search-recent"})]),o.addEventListener("input",()=>{if(dN=o.value,"loading"===d$.phase&&void 0!==d$.projectQuery&&d$.previous?.page==="projects"){let e=d$.previous;dM(),dH({phase:"view",view:e})}}),o.addEventListener("keydown",e=>{"Enter"===e.key&&(e.preventDefault(),n())}),r.addEventListener("submit",e=>e.preventDefault()),r),...void 0===t?[]:[dX("p",{class:"loading",role:"status","data-testid":"loading"},[`${"loading"===d$.phase?d$.label:"Loading projects…"} Previous results remain below.`])],s,dX("p",{class:"notice"},["Search registered project paths, aliases, IDs, or default branches. Only projects registered in this TraceDecay profile (via `tracedecay init`) can be explored."])]}function d7(e,t){let o=dX("input",{type:"search",name:"q",placeholder:`Search ${e.label}…`,value:t,"data-testid":"search-input","aria-label":"Search query"}),n=()=>{let t=o.value.trim();t.length>0&&dB(e.project_id,t)},r=dX("form",{class:"search","data-testid":"search-form"},[o,dY("Search",n,{class:"primary","data-testid":"search-submit"})]);return o.addEventListener("keydown",e=>{"Enter"===e.key&&(e.preventDefault(),n())}),r.addEventListener("submit",e=>e.preventDefault()),r}function he(e,t){if(null===e)return d5("Project status","provenance",[d2("Project status is reported once the project answers a request.","neutral","provenance-pending")]);let o=[["project",dX("code",{},[e.project_id])],["worktree",dX("code",{},[e.worktree_id??e.project_root])],["repository",dX("code",{},[e.repository_id??"—"])],["ref",e.reference??e.branch??"—"],["commit",dX("code",{"data-testid":"provenance-commit"},[e.commit??"—"])],["generation",dX("code",{},[e.generation??"—"])],["coverage",dX("span",{},[dQ(e.coverage.recall,"full"===e.coverage.recall?"ok":"warn","coverage-badge"),null===e.coverage.detail?"":` ${e.coverage.detail}`])],["daemon",null===e.authority?"unavailable":`${e.authority.daemon_version} \xb7 pid ${e.authority.daemon_pid}`],["deep link",dX("code",{"data-testid":"deep-link"},[dI(t)])]],n=dX("dl",{class:"prov"},o.flatMap(e=>{let[t,o]=e;return[dX("dt",{},[t]),dX("dd",{},[o])]})),r=dQ(e.freshness.state,d1(e.freshness.state),"freshness-badge"),i=[n];return null!==e.freshness.detail&&i.unshift(d2(e.freshness.detail,"ok"===d1(e.freshness.state)?"neutral":"warn","freshness-detail")),d5("Project status (separate read)","provenance",i,[r])}function ht(){let e=document.activeElement,t=e instanceof HTMLElement&&null!==e.closest('[data-testid="project-search-form"]')?e.dataset.testid:void 0,o=e instanceof HTMLInputElement&&"project-search-input"===e.dataset.testid?e:null,n=null===o?null:[o.selectionStart,o.selectionEnd,o.selectionDirection];if(dA.replaceChildren(function(e){let t=dX("nav",{class:"crumbs","aria-label":"Breadcrumb"});t.append(dY("Projects",dK,{"data-testid":"crumb-projects"}));let o=null!==e&&"projects"!==e.page?e.project:null;null!==o&&t.append("/",dY(o.label,()=>dF(o),{"data-testid":"crumb-project"})),e?.page==="search"&&""!==e.query&&t.append("/",dX("span",{},[`"${e.query}"`])),e?.page==="symbol"&&"ready"===e.symbol.state&&t.append("/",dX("span",{class:"mono"},[e.symbol.data.name]));let n=dX("header",{class:"bar"},[dX("h1",{},["TraceDecay"]),t,dX("span",{class:"spacer"})]),r=dO.getHostContext()?.displayMode;if(void 0!==r){let e="fullscreen"===r?"inline":"fullscreen";n.append(dY("fullscreen"===e?"Expand":"Collapse",()=>void dO.requestDisplayMode({mode:e}),{"data-testid":"toggle-display-mode"}))}return n}(dL()),...function(){switch(d$.phase){case"connecting":return[dX("p",{class:"loading","data-testid":"connecting"},["Connecting to ChatGPT…"])];case"loading":if(d$.previous?.page==="projects"&&void 0!==d$.projectQuery)return d3(d$.previous,d$.projectQuery);return[dX("p",{class:"loading","data-testid":"loading"},[d$.label])];case"host_error":return[d4({kind:"protocol",code:"host_call_failed",message:d$.message},"host-error"),dX("div",{class:"actions"},[dY("Back to projects",dK,{class:"primary","data-testid":"back-to-projects"})])];case"view":var e,t,o;switch(d$.view.page){case"projects":return d3(d$.view);case"search":let n;return n=d9((e=d$.view).results,"results",t=>{let o=[dX("ul",{class:"list"},t.hits.map(t=>dX("li",{"data-testid":"search-hit"},[dQ(t.kind),d6(e.project.project_id,t),d8(t)])))];if("partial"===t.recall&&o.push(d2("Partial recall: the index is still converging, so some matches may be missing.","warn","results-partial")),t.truncated&&o.push(d2("Results were truncated to the first page.","neutral","results-truncated")),t.undisplayable.length>0){let e=[...new Set(t.undisplayable)].join(", ");o.push(d2(`${t.undisplayable.length} candidate(s) could not be displayed (${e}).`,"warn","results-undisplayable"))}return"ready"===e.results.state&&void 0!==e.results.generation&&e.provenance?.generation!==void 0&&e.results.generation!==e.provenance.generation&&o.push(d2(`Results were served from generation ${e.results.generation}, which differs from the reported project generation ${e.provenance.generation}.`,"warn","results-generation")),dX("div",{},o)}),[d7(e.project,e.query),d5(""===e.query?"Search":`Results for "${e.query}"`,"results-panel",[n]),he(e.provenance,{kind:"search",project_id:e.project.project_id,query:e.query})];case"symbol":let r,i,a,s,l,c,u;return r=(t=d$.view).project.project_id,i=d9(t.symbol,"symbol",e=>{let t=[dX("p",{},[dQ(e.kind)," ",dX("code",{"data-testid":"symbol-qualified-name"},[e.qualified_name])," ",d8(e)])];return null!==e.signature&&t.push(dX("pre",{class:"mono"},[e.signature])),null!==e.complexity&&t.push(dX("p",{class:"notice"},[`Cyclomatic complexity ${e.complexity}`])),e.unavailable_fields.length>0&&t.push(d2(`Not available for this symbol: ${e.unavailable_fields.join(", ")}`,"neutral","symbol-unavailable-fields")),dX("div",{},t)}),a=(e,o)=>d9(e,o,n=>{let i=[dX("ul",{class:"list"},n.map(e=>dX("li",{"data-testid":`${o}-row`},[dQ(e.edge_kind),d6(r,e.symbol),d8(e.symbol),e.dispatch_via_trait?dQ("via trait"):""])))];return"ready"===e.state&&!0===e.truncated&&i.push(d2("Only the first page of relations is shown.","neutral",`${o}-truncated`)),"ready"===e.state&&void 0!==e.generation&&t.provenance?.generation!==void 0&&e.generation!==t.provenance.generation&&i.push(d2(`Served from generation ${e.generation}, which differs from the reported project generation ${t.provenance.generation}.`,"warn",`${o}-generation`)),dX("div",{},i)}),s=d9(t.impact,"impact",e=>{let t=[dX("ul",{class:"list"},e.nodes.map(e=>dX("li",{"data-testid":"impact-row"},[dQ(`depth ${e.depth}`),dY(e.name,()=>dV(r,e.node_id),{class:"mono"}),dX("span",{class:"loc"},[`${e.file}:${e.line}`])])))];return t.push(e.complete?d2(`Complete within depth ${e.max_depth}.`,"neutral","impact-complete"):d2(`Partial: traversal stopped at depth ${e.max_depth}.`,"warn","impact-partial")),dX("div",{},t)}),l=d9(t.graph,"graph",e=>(function(e,t){let o=t.nodes.filter(e=>"caller"===e.role),n=t.nodes.filter(e=>"callee"===e.role),r=t.nodes.find(e=>"focus"===e.role),i=28*Math.max(o.length,n.length,1)+20,a="http://www.w3.org/2000/svg",s=document.createElementNS(a,"svg");s.setAttribute("class","graph"),s.setAttribute("viewBox",`0 0 640 ${i}`),s.setAttribute("role","img"),s.setAttribute("aria-label","Bounded caller/callee graph"),s.dataset.testid="graph";let l={caller:10,focus:220,callee:430},c=new Map,u=(t,o,n)=>{let r=l[n],u="focus"===n?i/2-10:10+28*o;c.set(t.node_id,{x:r,y:u});let p=document.createElementNS(a,"g");p.setAttribute("class",`node ${t.role}`),p.dataset.testid="graph-node",p.dataset.nodeId=t.node_id;let d=document.createElementNS(a,"rect");d.setAttribute("x",String(r)),d.setAttribute("y",String(u)),d.setAttribute("width","200"),d.setAttribute("height","20"),d.setAttribute("rx","4");let h=document.createElementNS(a,"text");h.setAttribute("x",String(r+6)),h.setAttribute("y",String(u+14)),h.textContent=t.name.length>28?`${t.name.slice(0,27)}…`:t.name;let m=document.createElementNS(a,"title");m.textContent=`${t.qualified_name} (${t.file})`,p.append(m,d,h),"focus"!==t.role&&p.addEventListener("click",()=>dV(e,t.node_id)),s.append(p)};for(let e of(o.forEach((e,t)=>u(e,t,"caller")),n.forEach((e,t)=>u(e,t,"callee")),void 0!==r&&u(r,0,"focus"),t.edges)){let t=c.get(e.from),o=c.get(e.to);if(void 0===t||void 0===o)continue;let n=document.createElementNS(a,"path");n.setAttribute("class","edge"),n.setAttribute("d",`M ${t.x+200} ${t.y+10} C ${t.x+240} ${t.y+10}, ${o.x-40} ${o.y+10}, ${o.x} ${o.y+10}`),s.prepend(n)}let p=dX("div",{},[s]);return t.truncated&&p.append(d2(`Graph bounded to depth ${t.max_depth}; more relations exist than shown.`,"neutral","graph-truncated")),p})(r,e)),c=dX("div",{class:"actions"}),null!==(u=t.evidence)&&c.append(dY("Send evidence to chat",()=>void dO.sendMessage({role:"user",content:[{type:"text",text:u.markdown}]}),{class:"primary","data-testid":"send-evidence"}),dY("Re-share with model",()=>void dD(t),{"data-testid":"share-context"})),[d7(t.project,""),d5("Symbol","symbol-panel",[i,d2("Symbol and impact reads do not report a generation; their snapshot identity is unverified.","neutral","symbol-generation"),c]),d5("Callers","callers-panel",[a(t.callers,"callers")]),d5("Callees","callees-panel",[a(t.callees,"callees")]),d5("Impact","impact-panel",[s]),d5("Graph","graph-panel",[l]),he(t.provenance,"ready"===t.symbol.state?{kind:"symbol",project_id:r,node_id:t.symbol.data.node_id}:{kind:"project",project_id:r})];case"failure":let p;return p=[d4((o=d$.view).failure,"page-failure")],"denied"===o.failure.kind&&p.push(d2("Only projects registered in this TraceDecay profile can be explored. Run `tracedecay init` in the repository and reopen the workspace.","neutral","denied-help")),"disconnected"===o.failure.kind&&p.push(d2("Start the daemon with `tracedecay daemon run` (or any tracedecay command) and retry.","neutral","disconnected-help")),p.push(dX("div",{class:"actions"},[dY("Back to projects",dK,{class:"primary","data-testid":"back-to-projects"})])),null!==o.project&&p.unshift(d7(o.project,"")),[d5("Request failed","failure-panel",p)]}}}()),dA.dataset.phase=d$.phase,dA.dataset.page=dL()?.page??"",void 0!==t){let e=dA.querySelector(`[data-testid="${t}"]`);e?.focus({preventScroll:!0}),e instanceof HTMLInputElement&&null!==n&&e.setSelectionRange(...n)}}dO.ontoolinput=e=>{dM(),dH({phase:"loading",label:`Loading ${e.arguments?.query!==void 0?`search for "${String(e.arguments.query)}"`:"from TraceDecay"}…`,previous:dL()})},dO.ontoolresult=e=>{dM();let t=dJ(e);null===t?dH({phase:"host_error",message:"The tool result carried no TraceDecay view payload."}):dU(t)},dO.onhostcontextchanged=e=>{dG(e),ht()},dO.onteardown=async()=>(dM(),{}),window.addEventListener("pagehide",dM),ht(),dO.connect().then(()=>{let e=dO.getHostContext();void 0!==e&&dG(e),"connecting"===d$.phase&&dH({phase:"loading",label:"Waiting for TraceDecay…",previous:null})}).catch(e=>dH({phase:"host_error",message:e instanceof Error?e.message:String(e)}))})()})();