Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugin/chatgpt-extension/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
)

Expand Down
25 changes: 20 additions & 5 deletions plugin/chatgpt-extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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`,
Expand All @@ -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
Expand All @@ -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
Expand Down
18 changes: 9 additions & 9 deletions plugin/chatgpt-extension/embedded/app.html

Large diffs are not rendered by default.

20 changes: 10 additions & 10 deletions plugin/chatgpt-extension/embedded/server.mjs

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions plugin/chatgpt-extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
107 changes: 93 additions & 14 deletions plugin/chatgpt-extension/src/app/main.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/// <reference types="@rsbuild/core/types" />
import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps";
import type { CallToolResult } from "@modelcontextprotocol/client";
import {
Expand All @@ -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");
Expand All @@ -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;
Expand All @@ -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);
Expand All @@ -62,17 +73,25 @@ function viewFromResult(result: CallToolResult): ViewState | null {
return isViewState(result.structuredContent) ? result.structuredContent : null;
}

async function callTool(name: ToolName, args: Record<string, unknown>, label: string): Promise<void> {
setScreen({ phase: "loading", label, previous: currentView() });
async function callTool(name: ToolName, args: Record<string, unknown>, label: string, projectQuery?: string): Promise<void> {
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` });
return;
}
adoptView(view);
} catch (error) {
if (pendingCall !== controller) return;
pendingCall = null;
setScreen({ phase: "host_error", message: error instanceof Error ? error.message : String(error) });
}
}
Expand Down Expand Up @@ -129,7 +148,12 @@ function describeSection<T>(section: Section<T>, 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 {
Expand Down Expand Up @@ -273,25 +297,63 @@ function renderHeader(view: ViewState | null): HTMLElement {
return bar;
}

function renderProjects(view: Extract<ViewState, { page: "projects" }>): Node[] {
function renderProjects(view: Extract<ViewState, { page: "projects" }>, 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 {
Expand Down Expand Up @@ -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" })])];
Expand All @@ -509,17 +572,28 @@ 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, projectInput.selectionDirection] as const;
mount.replaceChildren(renderHeader(currentView()), ...renderBody());
mount.dataset["phase"] = screen.phase;
mount.dataset["page"] = currentView()?.page ?? "";
if (projectControl !== undefined) {
const replacement = mount.querySelector<HTMLElement>(`[data-testid="${projectControl}"]`);
replacement?.focus({ preventScroll: true });
if (replacement instanceof HTMLInputElement && selection !== null) 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);
Expand All @@ -528,6 +602,11 @@ app.onhostcontextchanged = (context) => {
applyHostContext(context);
render();
};
app.onteardown = async () => {
cancelPendingCall();
return {};
};
window.addEventListener("pagehide", cancelPendingCall);

render();
app
Expand Down
4 changes: 3 additions & 1 deletion plugin/chatgpt-extension/src/app/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand Down
23 changes: 20 additions & 3 deletions plugin/chatgpt-extension/src/server/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -118,11 +119,18 @@ export class DaemonBridge {
});
}

async listProjects(signal?: AbortSignal): Promise<readonly ProjectRef[]> {
async listProjects(signal?: AbortSignal): Promise<ProjectListing> {
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<ProjectListing> {
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<ProjectRef> {
Expand Down Expand Up @@ -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,
};
}
14 changes: 13 additions & 1 deletion plugin/chatgpt-extension/src/server/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
{
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading