From 9045310b3101c1291a3824b44110e3eddee64060 Mon Sep 17 00:00:00 2001 From: JGSnapp Date: Mon, 14 Sep 2026 13:13:42 +0300 Subject: [PATCH] fix(chatgpt): support copying conversations inside projects Conversations that belong to a ChatGPT project live under /g//c/, which the conversation URL pattern did not match, so copying failed with "Not a ChatGPT conversation page". Also surface AppError details in the copy toast instead of "Unknown error", since plugins throw plain AppError objects. Co-Authored-By: Claude Opus 5 --- .../src/hooks/use-copy-conversation.ts | 13 ++- .../plugins/chatgpt/__tests__/plugin.test.ts | 85 +++++++++++++++++++ .../src/plugins/chatgpt/plugin.ts | 3 +- 3 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 packages/core-plugins/src/plugins/chatgpt/__tests__/plugin.test.ts diff --git a/apps/browser-extension/src/hooks/use-copy-conversation.ts b/apps/browser-extension/src/hooks/use-copy-conversation.ts index e591505..9ce25dd 100644 --- a/apps/browser-extension/src/hooks/use-copy-conversation.ts +++ b/apps/browser-extension/src/hooks/use-copy-conversation.ts @@ -47,7 +47,7 @@ export function useCopyConversation() { setResult(null); }, 1500); } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error"; + const message = getErrorMessage(err); setError(message); setState("error"); @@ -60,3 +60,14 @@ export function useCopyConversation() { return { state, result, error, copy }; } + +// Plugins throw plain AppError objects ({ code, message, detail }), not Error instances +function getErrorMessage(err: unknown): string { + if (err instanceof Error) return err.message; + if (err && typeof err === "object") { + const { message, detail } = err as { message?: unknown; detail?: unknown }; + if (typeof detail === "string" && detail) return detail; + if (typeof message === "string" && message) return message; + } + return "Unknown error"; +} diff --git a/packages/core-plugins/src/plugins/chatgpt/__tests__/plugin.test.ts b/packages/core-plugins/src/plugins/chatgpt/__tests__/plugin.test.ts new file mode 100644 index 0000000..aa21ccb --- /dev/null +++ b/packages/core-plugins/src/plugins/chatgpt/__tests__/plugin.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { PluginContext } from "../../../types"; +import { chatgptPlugin } from "../plugin"; + +const CONVERSATION_ID = "68ab12cd-3456-7890-abcd-ef0123456789"; + +function mockChatGPTApi() { + const fetchMock = vi.fn(async (input: string) => { + if (input.endsWith("/api/auth/session")) { + return new Response(JSON.stringify({ accessToken: "token" })); + } + return new Response( + JSON.stringify({ + title: "Test", + current_node: "a", + mapping: { + u: { + id: "u", + parent: null, + children: ["a"], + message: { + author: { role: "user" }, + content: { content_type: "text", parts: ["Hello"] }, + }, + }, + a: { + id: "a", + parent: "u", + children: [], + message: { + author: { role: "assistant" }, + content: { content_type: "text", parts: ["Hi"] }, + }, + }, + }, + }), + ); + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +function ctx(url: string): PluginContext { + return { url, document: {} as Document }; +} + +describe("chatgptPlugin.extract", () => { + beforeEach(() => { + vi.unstubAllGlobals(); + }); + + it("extracts a regular conversation", async () => { + const fetchMock = mockChatGPTApi(); + const bundle = await chatgptPlugin.extract( + ctx(`https://chatgpt.com/c/${CONVERSATION_ID}`), + ); + expect(bundle.nodes).toHaveLength(2); + expect(fetchMock).toHaveBeenCalledWith( + `https://chatgpt.com/backend-api/conversation/${CONVERSATION_ID}`, + expect.anything(), + ); + }); + + it("extracts a conversation inside a project", async () => { + const fetchMock = mockChatGPTApi(); + const bundle = await chatgptPlugin.extract( + ctx( + `https://chatgpt.com/g/g-p-67f0a1b2c3d4-vosstanovlenie-saita/c/${CONVERSATION_ID}`, + ), + ); + expect(bundle.nodes).toHaveLength(2); + expect(fetchMock).toHaveBeenCalledWith( + `https://chatgpt.com/backend-api/conversation/${CONVERSATION_ID}`, + expect.anything(), + ); + }); + + it("rejects non-conversation pages", async () => { + await expect( + chatgptPlugin.extract( + ctx("https://chatgpt.com/g/g-p-67f0a1b2c3d4/project"), + ), + ).rejects.toMatchObject({ code: "E-PARSE-001" }); + }); +}); diff --git a/packages/core-plugins/src/plugins/chatgpt/plugin.ts b/packages/core-plugins/src/plugins/chatgpt/plugin.ts index 813c7c5..cd583b2 100644 --- a/packages/core-plugins/src/plugins/chatgpt/plugin.ts +++ b/packages/core-plugins/src/plugins/chatgpt/plugin.ts @@ -9,8 +9,9 @@ import { buildLinearConversation } from "./tree-linearizer"; import type { ChatGPTConversationResponse, MessageNode } from "./types"; const HOST_PATTERN = /^https:\/\/(?:chatgpt\.com|chat\.openai\.com)\//i; +// Matches both /c/ and project conversations at /g//c/ const CONVERSATION_PATTERN = - /^https?:\/\/(?:chat\.openai\.com|chatgpt\.com)\/c\/([a-zA-Z0-9-]+)/; + /^https?:\/\/(?:chat\.openai\.com|chatgpt\.com)\/(?:g\/[^/]+\/)?c\/([a-zA-Z0-9-]+)/; const SESSION_ENDPOINT = "https://chatgpt.com/api/auth/session"; const API_ENDPOINT = "https://chatgpt.com/backend-api/conversation";