Skip to content
Open
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
4 changes: 2 additions & 2 deletions apps/amp-plugin/plannotator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,8 +262,8 @@ describe("Amp Plannotator plugin helpers", () => {
},
}),
).toEqual([
[String.raw`C:\Users\alice\AppData\Local/plannotator/plannotator.exe`],
[String.raw`C:\Users\alice/.local/bin/plannotator.exe`],
[String.raw`C:\Users\alice\AppData\Local\plannotator\plannotator.exe`],
[String.raw`C:\Users\alice\.local\bin\plannotator.exe`],
["plannotator"],
]);
});
Expand Down
48 changes: 28 additions & 20 deletions apps/amp-plugin/plannotator.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { PluginAPI, PluginCommandContext, ThreadMessage } from "@ampcode/plugin";
import { existsSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
import { dirname, join, posix, resolve, win32 } from "node:path";
import { fileURLToPath as nodeFileURLToPath } from "node:url";
import { randomUUID } from "node:crypto";

const CATEGORY = "Plannotator";
Expand Down Expand Up @@ -529,11 +530,7 @@ function normalizeWorkspaceRoot(value: unknown): string | null {
function fileUrlToPath(value: string): string {
const url = new URL(value);
if (url.protocol !== "file:") throw new Error(`Unsupported URL protocol: ${url.protocol}`);

const pathname = decodeURIComponent(url.pathname);
return process.platform === "win32" && /^\/[A-Za-z]:/.test(pathname)
? pathname.slice(1)
: pathname;
return nodeFileURLToPath(url);
}

export function buildPlannotatorEnv(cwd: string, readyFile: string | null): Record<string, string> {
Expand All @@ -549,7 +546,7 @@ function normalizeDirectory(value: string | undefined): string | null {
if (!candidate || candidate === "undefined" || candidate === "null") return null;

try {
return statSync(candidate).isDirectory() ? candidate : null;
return statSync(candidate).isDirectory() ? resolve(candidate) : null;
} catch {
return null;
}
Expand Down Expand Up @@ -710,23 +707,24 @@ export function getPlannotatorCommandCandidates(
} = {},
): string[][] {
const env = options.env ?? process.env;
const homes = getHomeDirectoryCandidates(env, options.home, options.pluginDir ?? import.meta.dir);
const platform = options.platform ?? process.platform;
const homes = getHomeDirectoryCandidates(env, options.home, options.pluginDir ?? import.meta.dir, platform);
const pathJoin = joinForPlatform(platform);
const candidates: string[][] = [];

const explicitBin = normalizeExecutablePath(env.PLANNOTATOR_BIN);
if (explicitBin) candidates.push([explicitBin]);

if (platform === "win32") {
const localAppData = normalizeExecutablePath(env.LOCALAPPDATA);
if (localAppData) candidates.push([join(localAppData, "plannotator", "plannotator.exe")]);
if (localAppData) candidates.push([pathJoin(localAppData, "plannotator", "plannotator.exe")]);

for (const home of homes) {
candidates.push([join(home, ".local", "bin", "plannotator.exe")]);
candidates.push([pathJoin(home, ".local", "bin", "plannotator.exe")]);
}
} else {
for (const home of homes) {
candidates.push([join(home, ".local", "bin", "plannotator")]);
candidates.push([pathJoin(home, ".local", "bin", "plannotator")]);
}
}

Expand All @@ -744,32 +742,42 @@ function getHomeDirectoryCandidates(
env: Record<string, string | undefined>,
explicitHome: string | undefined,
pluginDir: string,
platform: string,
): string[] {
return dedupeStrings([
normalizeExecutablePath(explicitHome),
normalizeExecutablePath(env.HOME),
normalizeExecutablePath(env.USERPROFILE),
deriveHomeFromAmpPluginDir(pluginDir),
deriveHomeFromAmpPluginDir(pluginDir, platform),
explicitHome === undefined ? normalizeExecutablePath(homedir()) : null,
]);
}

function deriveHomeFromAmpPluginDir(pluginDir: string): string | null {
const pluginsDir = resolve(pluginDir);
const ampDir = dirname(pluginsDir);
const configDir = dirname(ampDir);
function deriveHomeFromAmpPluginDir(pluginDir: string, platform: string): string | null {
const pathApi = pathApiForPlatform(platform);
const pluginsDir = pathApi.resolve(pluginDir);
const ampDir = pathApi.dirname(pluginsDir);
const configDir = pathApi.dirname(ampDir);

if (
basename(pluginsDir) === "plugins" &&
basename(ampDir) === "amp" &&
basename(configDir) === ".config"
pathApi.basename(pluginsDir) === "plugins" &&
pathApi.basename(ampDir) === "amp" &&
pathApi.basename(configDir) === ".config"
) {
return dirname(configDir);
return pathApi.dirname(configDir);
}

return null;
}

function pathApiForPlatform(platform: string): typeof posix | typeof win32 {
return platform === "win32" ? win32 : posix;
}

function joinForPlatform(platform: string): typeof posix.join {
return pathApiForPlatform(platform).join;
}

function getAmpCacheDir(): string {
const cacheHome = normalizeExecutablePath(process.env.XDG_CACHE_HOME);
return cacheHome ? join(cacheHome, "amp") : join(homedir(), ".cache", "amp");
Expand Down
31 changes: 31 additions & 0 deletions apps/pi-extension/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1007,6 +1007,37 @@ describe("pi review server", () => {
}, 20_000);
});

describe("pi plan archive server", () => {
test("serves an empty archived plan as a found plan", async () => {
const archiveDir = makeTempDir("plannotator-pi-archive-");
const filename = "2026-01-02-empty-approved.md";
writeFileSync(join(archiveDir, filename), "", "utf-8");
process.env.PLANNOTATOR_PORT = String(await reservePort());

const server = await startPlanReviewServer({
plan: "",
origin: "pi",
htmlContent: "<!doctype html><html><body>archive</body></html>",
mode: "archive",
customPlanPath: archiveDir,
});

try {
const url = new URL(`${server.url}/api/archive/plan`);
url.searchParams.set("filename", filename);
url.searchParams.set("customPath", archiveDir);
const response = await fetch(url);
const payload = await response.json() as { markdown?: string; filepath?: string };

expect(response.status).toBe(200);
expect(payload.markdown).toBe("");
expect(payload.filepath).toBe(filename);
} finally {
server.stop();
}
});
});

describe("pi plan server file browser", () => {
test("filters excluded folders from tree and workspace status", async () => {
const repo = makeTempDir("plannotator-pi-files-");
Expand Down
5 changes: 3 additions & 2 deletions apps/pi-extension/server/serverPlan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ export async function startPlanReviewServer(options: {
return;
}
const markdown = readArchivedPlan(filename, customPath);
if (!markdown) {
if (markdown === null) {
json(res, { error: "Not found" }, 404);
return;
}
Expand Down Expand Up @@ -251,11 +251,12 @@ export async function startPlanReviewServer(options: {
});
} else if (url.pathname === "/api/config" && req.method === "POST") {
try {
const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record<string, unknown>; conventionalComments?: boolean; pfmReminder?: boolean };
const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record<string, unknown>; conventionalComments?: boolean; conventionalLabels?: unknown[] | null; pfmReminder?: boolean };
const toSave: Record<string, unknown> = {};
if (body.displayName !== undefined) toSave.displayName = body.displayName;
if (body.diffOptions !== undefined) toSave.diffOptions = body.diffOptions;
if (body.conventionalComments !== undefined) toSave.conventionalComments = body.conventionalComments;
if (body.conventionalLabels !== undefined) toSave.conventionalLabels = body.conventionalLabels;
if (body.pfmReminder !== undefined) toSave.pfmReminder = body.pfmReminder;
if (Object.keys(toSave).length > 0) saveConfig(toSave as Parameters<typeof saveConfig>[0]);
json(res, { ok: true });
Expand Down
64 changes: 64 additions & 0 deletions packages/ui/hooks/useAIChat.seam.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,19 @@ function makeSseResponse(textDelta: string): Response {
});
}

function makeAbortableSseResponse(signal: AbortSignal): Response {
return new Response(new ReadableStream({
start(controller) {
signal.addEventListener('abort', () => {
controller.error(new DOMException('aborted', 'AbortError'));
}, { once: true });
},
}), {
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
});
}

async function mountHook(context: AIContext | null): Promise<{
result: { current: HookResult | null };
unmount: () => Promise<void>;
Expand All @@ -73,6 +86,49 @@ async function mountHook(context: AIContext | null): Promise<{
};
}

async function expectResetAbortsActiveTurn(resetKind: 'session' | 'thread'): Promise<void> {
const abortBodies: unknown[] = [];
let resolveQueryStarted: () => void = () => {};
const queryStarted = new Promise<void>((resolve) => {
resolveQueryStarted = resolve;
});

const fakeTransport: AITransport = {
session: async () => new Response(JSON.stringify({ sessionId: 'fake-session-reset' }), { status: 200 }),
query: async (_body, signal) => {
resolveQueryStarted();
return makeAbortableSseResponse(signal);
},
abort: async (body) => { abortBodies.push(body); },
permission: () => {},
};

setAITransport(fakeTransport);

const session = await mountHook(TEST_CONTEXT);
let askPromise: Promise<void> = Promise.resolve();

await act(async () => {
askPromise = session.result.current!.ask({ prompt: 'slow question' });
});
await queryStarted;

await act(async () => {
if (resetKind === 'session') {
session.result.current!.resetSession();
} else {
session.result.current!.resetThread();
}
});

await act(async () => { await new Promise<void>((r) => setTimeout(r, 0)); });
await askPromise;

expect(abortBodies).toEqual([{ sessionId: 'fake-session-reset' }]);

await session.unmount();
}

describe('AITransport seam', () => {
test.skipIf(!hasDom)('fake session + query are called when useAIChat.ask() is invoked', async () => {
const sessionBodies: unknown[] = [];
Expand Down Expand Up @@ -148,4 +204,12 @@ describe('AITransport seam', () => {

await session.unmount();
});

test.skipIf(!hasDom)('resetSession aborts the active server turn', async () => {
await expectResetAbortsActiveTurn('session');
});

test.skipIf(!hasDom)('resetThread aborts the active server turn', async () => {
await expectResetAbortsActiveTurn('thread');
});
});
7 changes: 5 additions & 2 deletions packages/ui/hooks/useAIChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ export function useAIChat({
}, []);

const setSessionId = useCallback((sessionId: string | null) => {
sessionIdRef.current = sessionId;
setThread(prev => ({ ...prev, sessionId }));
}, []);

Expand Down Expand Up @@ -486,24 +487,26 @@ export function useAIChat({
if (abortRef.current) {
abortRef.current.abort();
abortRef.current = null;
pendingAbortRef.current = postServerAbort();
}
setSessionId(null);
setIsCreatingSession(false);
setIsStreaming(false);
}, [setSessionId]);
}, [postServerAbort, setSessionId]);

const resetThread = useCallback(() => {
sessionEpochRef.current += 1;
createRequestRef.current += 1;
if (abortRef.current) {
abortRef.current.abort();
abortRef.current = null;
pendingAbortRef.current = postServerAbort();
}
setThread(createThread(threadTitle));
setIsCreatingSession(false);
setIsStreaming(false);
setError(null);
}, [threadTitle]);
}, [postServerAbort, threadTitle]);

useEffect(() => {
return () => {
Expand Down
3 changes: 1 addition & 2 deletions packages/ui/hooks/useUpdateCheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,7 @@ export function useUpdateCheck(): UpdateInfo | null {
releaseUrl: release.html_url,
featureHighlight,
});
} catch (e) {
console.debug('Update check failed:', e);
} catch {
}
};

Expand Down
1 change: 0 additions & 1 deletion packages/ui/utils/sharing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,6 @@ export async function createShortShareUrl(
}
// Service unavailable — expected for self-hosted setups without a paste backend.
// The caller is responsible for falling back to hash-based sharing silently.
console.debug('[sharing] Short URL service unavailable, using hash-based sharing:', e);
return null;
}
}
Expand Down
Loading