diff --git a/README.md b/README.md index 79809a7..0e35da6 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,13 @@ Before publishing a build that enables guest AI, verify the Firebase project use - [LeetCode](https://leetcode.com/problemset/) - [Programmers / 프로그래머스](https://school.programmers.co.kr/learn/challenges) -The current Chrome Web Store release is version `1.1.0`. +The manifest and submission package target version `1.2.0`. This repository does not +indicate whether Chrome Web Store review or publication has completed. + +Before submitting 1.2.0, merge the release PR and verify the public privacy policy +shows version `1.2.0` and the date `2026-09-05`. Keep the Web Store privacy disclosures +consistent with guest backend processing, optional authentication and sync, and +user-requested transmission of problem context, code attempts, and conversation history. ## Tech stack diff --git a/assets/privacy-policy.html b/assets/privacy-policy.html index 7e67e81..68c128c 100644 --- a/assets/privacy-policy.html +++ b/assets/privacy-policy.html @@ -97,7 +97,8 @@
@@ -177,7 +178,8 @@
Locally stored data remains on your device unless you delete it, uninstall the extension, - clear Chrome extension storage, or enable optional cloud sync. + clear Chrome extension storage, or enable optional cloud sync. Relevant locally stored content + may also be transmitted when you request an AI feature, as described below.
@@ -280,6 +284,27 @@
+ Starting the free trial creates an anonymous Firebase Authentication session; no email or + Google account is required for this session. Checking an existing trial may refresh its + authentication tokens and contact the CodeCoach backend without sending code or a question. + This authentication and quota traffic is separate from an AI request. +
++ The guest backend stores the Firebase user ID, request counts, token and cost totals, + recent request identifiers for duplicate protection, and timestamps in Firestore. + It also stores daily request counters under a keyed hash derived from the request IP address, + and aggregate daily budget counters. A hashed guest identifier is sent to OpenAI for abuse + monitoring. These identifiers are pseudonymous, not a guarantee of anonymity. +
++ Coaching questions, code, and responses are processed to complete the request and are not + written to the guest quota documents. Firebase / Google infrastructure may retain HTTP request + metadata, including IP addresses, and operational logs. The backend logs errors, which may + include upstream diagnostic messages; therefore this policy does not promise zero server logging. +
+CodeCoach does not collect or store the following as developer-controlled data: @@ -521,11 +546,11 @@
+ Clearing local guest-session data or uninstalling the extension does not delete the anonymous + Firebase account or server-side quota records, and does not reset the free-use allowance. + Contact the developer for assistance deleting server-side records; we may need information + to locate and verify your session, but you should not send API keys or authentication tokens. +
You may request access to, correction of, deletion of, or suspension of processing of developer-controlled data associated with your CodeCoach account by contacting the developer @@ -585,11 +616,21 @@
+ Inline coaching prompts can appear automatically inside the editor on supported pages, + without opening the side panel. The extension locally observes editor content, cursor position, + editing activity, and visible run or submission results to position the prompt and decide + when to offer help. Displaying an invitation to ask for a hint does not itself send the + editor content to OpenAI. Hide collapses the inline coach to a Show coach control; + reopening a saved hint does not make another AI request. Inline coaching can be disabled + in the extension settings. +
CodeCoach does not automatically send your data to OpenAI merely because you visit a page. AI-related data is sent when you use an AI feature, such as asking a question, requesting a hint, @@ -599,7 +640,17 @@
When you use an AI feature, the information sent to OpenAI is limited to what is needed to provide the requested response, such as your prompt, current code, selected text, - visible problem context, and relevant run or test-result messages. + visible problem context, relevant conversation history, saved code attempts when needed + for notes or comparisons, and relevant run or test-result messages. Inline coaching uses + the same BYOK or guest request paths as side-panel coaching. +
+ +
+ AI requests use store: false. This is not a promise of zero retention:
+ OpenAI may retain data for abuse monitoring or other purposes permitted by its applicable
+ API policies and account settings. Your own API key is stored locally and is not uploaded
+ to the CodeCoach guest backend; the guest service uses a server-managed key.
+ See OpenAI API data controls.
diff --git a/docs/privacy-policy.html b/docs/privacy-policy.html index 7e67e81..68c128c 100644 --- a/docs/privacy-policy.html +++ b/docs/privacy-policy.html @@ -97,7 +97,8 @@
@@ -177,7 +178,8 @@
Locally stored data remains on your device unless you delete it, uninstall the extension, - clear Chrome extension storage, or enable optional cloud sync. + clear Chrome extension storage, or enable optional cloud sync. Relevant locally stored content + may also be transmitted when you request an AI feature, as described below.
@@ -280,6 +284,27 @@
+ Starting the free trial creates an anonymous Firebase Authentication session; no email or + Google account is required for this session. Checking an existing trial may refresh its + authentication tokens and contact the CodeCoach backend without sending code or a question. + This authentication and quota traffic is separate from an AI request. +
++ The guest backend stores the Firebase user ID, request counts, token and cost totals, + recent request identifiers for duplicate protection, and timestamps in Firestore. + It also stores daily request counters under a keyed hash derived from the request IP address, + and aggregate daily budget counters. A hashed guest identifier is sent to OpenAI for abuse + monitoring. These identifiers are pseudonymous, not a guarantee of anonymity. +
++ Coaching questions, code, and responses are processed to complete the request and are not + written to the guest quota documents. Firebase / Google infrastructure may retain HTTP request + metadata, including IP addresses, and operational logs. The backend logs errors, which may + include upstream diagnostic messages; therefore this policy does not promise zero server logging. +
+CodeCoach does not collect or store the following as developer-controlled data: @@ -521,11 +546,11 @@
+ Clearing local guest-session data or uninstalling the extension does not delete the anonymous + Firebase account or server-side quota records, and does not reset the free-use allowance. + Contact the developer for assistance deleting server-side records; we may need information + to locate and verify your session, but you should not send API keys or authentication tokens. +
You may request access to, correction of, deletion of, or suspension of processing of developer-controlled data associated with your CodeCoach account by contacting the developer @@ -585,11 +616,21 @@
+ Inline coaching prompts can appear automatically inside the editor on supported pages, + without opening the side panel. The extension locally observes editor content, cursor position, + editing activity, and visible run or submission results to position the prompt and decide + when to offer help. Displaying an invitation to ask for a hint does not itself send the + editor content to OpenAI. Hide collapses the inline coach to a Show coach control; + reopening a saved hint does not make another AI request. Inline coaching can be disabled + in the extension settings. +
CodeCoach does not automatically send your data to OpenAI merely because you visit a page. AI-related data is sent when you use an AI feature, such as asking a question, requesting a hint, @@ -599,7 +640,17 @@
When you use an AI feature, the information sent to OpenAI is limited to what is needed to provide the requested response, such as your prompt, current code, selected text, - visible problem context, and relevant run or test-result messages. + visible problem context, relevant conversation history, saved code attempts when needed + for notes or comparisons, and relevant run or test-result messages. Inline coaching uses + the same BYOK or guest request paths as side-panel coaching. +
+ +
+ AI requests use store: false. This is not a promise of zero retention:
+ OpenAI may retain data for abuse monitoring or other purposes permitted by its applicable
+ API policies and account settings. Your own API key is stored locally and is not uploaded
+ to the CodeCoach guest backend; the guest service uses a server-managed key.
+ See OpenAI API data controls.
diff --git a/landing/extension-policy.test.ts b/landing/extension-policy.test.ts index 8c6ce59..35685af 100644 --- a/landing/extension-policy.test.ts +++ b/landing/extension-policy.test.ts @@ -6,6 +6,25 @@ import { resolveAiAccessMode } from "../src/sidepanel/aiAccessMode.js"; const read = (relativePath: string) => fs.readFileSync(path.join(process.cwd(), relativePath), "utf8"); describe("extension AI access and privacy policy", () => { + it("keeps the 1.2.0 release metadata and shipped/hosted policies aligned", () => { + const manifest = JSON.parse(read("manifest.json")); + expect(manifest.version).toBe("1.2.0"); + expect(JSON.parse(read("package.json")).version).toBe(manifest.version); + const lock = JSON.parse(read("package-lock.json")); + expect(lock.version).toBe(manifest.version); + expect(lock.packages[""].version).toBe(manifest.version); + const policy = read("assets/privacy-policy.html"); + expect(read("landing/public/privacy-policy.html")).toBe(policy); + expect(read("docs/privacy-policy.html")).toBe(policy); + expect(policy).toContain("Version: 1.2.0"); + expect(policy).toContain("Last updated: 2026-09-05"); + expect(policy).toContain("Inline coaching prompts can appear automatically"); + expect(policy).toContain("failed and passed snapshots"); + expect(policy).toContain("Guest quota and operational records"); + expect(policy).toContain("not a promise of zero retention"); + expect(policy).toContain("does not implement automatic expiry"); + }); + it("uses supported low reasoning for GPT-5 inline and BYOK requests", () => { expect(read("src/background/service-worker.js")).toContain('return { effort: "low" };'); expect(read("src/background/coach-router.js")).toContain('return { effort: "low" };'); diff --git a/landing/public/privacy-policy.html b/landing/public/privacy-policy.html index 7e67e81..68c128c 100644 --- a/landing/public/privacy-policy.html +++ b/landing/public/privacy-policy.html @@ -97,7 +97,8 @@
@@ -177,7 +178,8 @@
Locally stored data remains on your device unless you delete it, uninstall the extension, - clear Chrome extension storage, or enable optional cloud sync. + clear Chrome extension storage, or enable optional cloud sync. Relevant locally stored content + may also be transmitted when you request an AI feature, as described below.
@@ -280,6 +284,27 @@
+ Starting the free trial creates an anonymous Firebase Authentication session; no email or + Google account is required for this session. Checking an existing trial may refresh its + authentication tokens and contact the CodeCoach backend without sending code or a question. + This authentication and quota traffic is separate from an AI request. +
++ The guest backend stores the Firebase user ID, request counts, token and cost totals, + recent request identifiers for duplicate protection, and timestamps in Firestore. + It also stores daily request counters under a keyed hash derived from the request IP address, + and aggregate daily budget counters. A hashed guest identifier is sent to OpenAI for abuse + monitoring. These identifiers are pseudonymous, not a guarantee of anonymity. +
++ Coaching questions, code, and responses are processed to complete the request and are not + written to the guest quota documents. Firebase / Google infrastructure may retain HTTP request + metadata, including IP addresses, and operational logs. The backend logs errors, which may + include upstream diagnostic messages; therefore this policy does not promise zero server logging. +
+CodeCoach does not collect or store the following as developer-controlled data: @@ -521,11 +546,11 @@
+ Clearing local guest-session data or uninstalling the extension does not delete the anonymous + Firebase account or server-side quota records, and does not reset the free-use allowance. + Contact the developer for assistance deleting server-side records; we may need information + to locate and verify your session, but you should not send API keys or authentication tokens. +
You may request access to, correction of, deletion of, or suspension of processing of developer-controlled data associated with your CodeCoach account by contacting the developer @@ -585,11 +616,21 @@
+ Inline coaching prompts can appear automatically inside the editor on supported pages, + without opening the side panel. The extension locally observes editor content, cursor position, + editing activity, and visible run or submission results to position the prompt and decide + when to offer help. Displaying an invitation to ask for a hint does not itself send the + editor content to OpenAI. Hide collapses the inline coach to a Show coach control; + reopening a saved hint does not make another AI request. Inline coaching can be disabled + in the extension settings. +
CodeCoach does not automatically send your data to OpenAI merely because you visit a page. AI-related data is sent when you use an AI feature, such as asking a question, requesting a hint, @@ -599,7 +640,17 @@
When you use an AI feature, the information sent to OpenAI is limited to what is needed to provide the requested response, such as your prompt, current code, selected text, - visible problem context, and relevant run or test-result messages. + visible problem context, relevant conversation history, saved code attempts when needed + for notes or comparisons, and relevant run or test-result messages. Inline coaching uses + the same BYOK or guest request paths as side-panel coaching. +
+ +
+ AI requests use store: false. This is not a promise of zero retention:
+ OpenAI may retain data for abuse monitoring or other purposes permitted by its applicable
+ API policies and account settings. Your own API key is stored locally and is not uploaded
+ to the CodeCoach guest backend; the guest service uses a server-managed key.
+ See OpenAI API data controls.
diff --git a/package-lock.json b/package-lock.json index cffa577..f1fec2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codecoach", - "version": "1.0.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codecoach", - "version": "1.0.0", + "version": "1.2.0", "dependencies": { "react": "19.2.8", "react-dom": "19.2.8" diff --git a/package.json b/package.json index bf43522..e8f7529 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "codecoach", "private": true, - "version": "1.0.0", + "version": "1.2.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src/content/inline-nudge-bridge.js b/src/content/inline-nudge-bridge.js index d1df1b6..9737988 100644 --- a/src/content/inline-nudge-bridge.js +++ b/src/content/inline-nudge-bridge.js @@ -264,6 +264,7 @@ } function ghostText(view) { + if (view?.collapsed) return ""; return [view?.tone === "success" ? "OK" : "CodeCoach", view?.title, view?.body] .filter(Boolean) .join(": ") @@ -289,6 +290,7 @@ input = document.createElement("input"); input.type = "text"; input.placeholder = view.inputPlaceholder || ""; + input.value = view.inputValue || ""; input.dataset.inlineInput = "1"; input.addEventListener("keydown", (event) => { if (event.key === "Enter" && !event.shiftKey && view?.primaryAction) { @@ -357,6 +359,7 @@ ghostHost.className = "codecoach-inline-ghost-overlay"; ghostHost.dataset.codecoachInlineUi = "1"; ghostHost.textContent = ghostText(view); + ghostHost.hidden = Boolean(view.collapsed); container.appendChild(ghostHost); controlsHost = createControls(view); @@ -395,8 +398,8 @@ ghostHost.style.maxHeight = `${Math.max(18, rect.height - lineTop - controlsHeight - 12)}px`; ghostHost.style.overflowY = "auto"; ghostHost.style.pointerEvents = "auto"; - const answerHeight = ghostHost.getBoundingClientRect().height || coordinates.height; - controlsHost.style.top = `${lineTop + answerHeight + 6}px`; + const answerHeight = ghostHost.hidden ? 0 : ghostHost.getBoundingClientRect().height || coordinates.height; + controlsHost.style.top = `${lineTop + answerHeight + (ghostHost.hidden ? 0 : 6)}px`; } } diff --git a/src/content/inline-nudge-bridge.test.js b/src/content/inline-nudge-bridge.test.js index b56a87e..ee0beba 100644 --- a/src/content/inline-nudge-bridge.test.js +++ b/src/content/inline-nudge-bridge.test.js @@ -77,6 +77,33 @@ describe("inline nudge page bridge", () => { expect(ghost.style.top).toBe("80px"); }); + it.each(["monaco-editor", "CodeMirror"])("keeps the reopen control at the code indentation in %s", (className) => { + const container = createContainer(className); + if (className === "monaco-editor") { + window.monaco = { editor: { getEditors: () => [createMonacoEditor(container, { + getLineCount: () => 3, getLineContent: () => " return answer" + })] } }; + } else { + container.CodeMirror = { + getValue: () => " return answer", getCursor: () => ({ line: 2, ch: 4 }), + getLine: () => " return answer", lineCount: () => 3, + getWrapperElement: () => container, cursorCoords: () => ({ left: 120, top: 42, bottom: 60 }), + on: vi.fn(), off: vi.fn() + }; + } + const postMessage = vi.spyOn(window, "postMessage"); + runBridge(); + dispatchWindowMessage({ ...renderMessage, view: { collapsed: true, primaryAction: "reopen", primaryLabel: "Show coach" } }); + const ghost = container.querySelector(".codecoach-inline-ghost-overlay"); + const controls = container.querySelector(".codecoach-inline-controls"); + expect(ghost.hidden).toBe(true); + expect(ghost.textContent).toBe(""); + expect(controls.style.left).toBe("120px"); + expect(controls.style.top).toBe("60px"); + controls.querySelector("button").click(); + expect(postMessage).toHaveBeenCalledWith(expect.objectContaining({ action: "reopen", token: "render-token" }), "*"); + }); + it("mounts the same visible overlay for CodeMirror", () => { const wrapper = createContainer("CodeMirror", { left: 40, top: 60, width: 500, height: 300 }); wrapper.CodeMirror = { diff --git a/src/content/inline-nudge-reopen.test.js b/src/content/inline-nudge-reopen.test.js new file mode 100644 index 0000000..8cc7036 --- /dev/null +++ b/src/content/inline-nudge-reopen.test.js @@ -0,0 +1,97 @@ +import { readFileSync } from "node:fs"; +import { runInNewContext } from "node:vm"; +import { afterEach, expect, it, vi } from "vitest"; + +const source = readFileSync(`${process.cwd()}/src/content/inline-nudge.js`, "utf8"); +afterEach(() => { vi.clearAllTimers(); vi.useRealTimers(); }); + +async function boot(uiLanguage = "en") { + vi.useFakeTimers(); + let onWindowMessage; + let onRuntimeMessage; + const context = { allowed: true, problemUrl: "https://leetcode.com/problems/two-sum/", code: " return answer" }; + const window = { addEventListener: (_type, fn) => { onWindowMessage = fn; }, postMessage: vi.fn() }; + const sendMessage = vi.fn((message, callback) => callback(message.type === "GET_SETTINGS" + ? { ok: true, settings: { uiLanguage } } + : message.type === "GET_ACTIVE_CONTEXT" ? { ok: true, context } : { ok: true })); + runInNewContext(source, { + window, document: { visibilityState: "visible" }, Date, setTimeout, setInterval, crypto, + chrome: { runtime: { sendMessage, onMessage: { addListener: (fn) => { onRuntimeMessage = fn; } } } } + }); + const post = (data) => onWindowMessage({ source: window, data }); + post({ source: "CODING_HINT_COACH_EDITOR_ACTIVITY", editorType: "monaco" }); + await vi.advanceTimersByTimeAsync(1600); + const latest = () => window.postMessage.mock.calls.map(([data]) => data).filter(data => data.source === "CODING_HINT_COACH_INLINE_RENDER").at(-1); + const action = (action, value = "") => post({ source: "CODING_HINT_COACH_INLINE_ACTION", token: latest().token, action, value }); + return { latest, action, context, sendMessage, runtime: (data) => onRuntimeMessage(data) }; +} + +it.each([["en", "Show coach"], ["ko", "코치 다시 보기"]])("can repeatedly hide and reopen without an AI request (%s)", async (language, label) => { + const h = await boot(language); + const initial = h.latest().view; + for (let i = 0; i < 4; i++) { + h.action("dismiss"); + expect(h.latest().view).toMatchObject({ collapsed: true, primaryAction: "reopen", primaryLabel: label }); + await vi.advanceTimersByTimeAsync(130000); + expect(h.latest().view.collapsed).toBe(true); + h.action("reopen"); + expect(h.latest().view).toEqual(initial); + } + expect(h.sendMessage.mock.calls.some(([m]) => m.type === "STREAM_INLINE_AI")).toBe(false); +}); + +it("restores a completed hint without spending quota and resets on another problem", async () => { + const h = await boot(); + h.action("hint"); + await vi.advanceTimersByTimeAsync(0); + const request = h.sendMessage.mock.calls.find(([m]) => m.type === "STREAM_INLINE_AI")[0]; + h.runtime({ type: "INLINE_AI_DONE", requestId: request.requestId, rawText: "Consider what you need to remember.", trial: { remaining: 9 } }); + const answer = h.latest().view; + h.action("dismiss"); + h.action("reopen"); + expect(h.latest().view).toEqual(answer); + expect(h.sendMessage.mock.calls.filter(([m]) => m.type === "STREAM_INLINE_AI")).toHaveLength(1); + h.action("dismiss"); + h.context.problemUrl = "https://school.programmers.co.kr/learn/courses/30/lessons/42626"; + await vi.advanceTimersByTimeAsync(3100); + expect(h.latest().view.collapsed).not.toBe(true); + expect(h.latest().view.title).toBe("How do you want to start?"); +}); + +it("does not restore Thinking or let late responses reopen a hidden coach", async () => { + const h = await boot(); + h.action("hint"); + await vi.advanceTimersByTimeAsync(0); + const request = h.sendMessage.mock.calls.find(([m]) => m.type === "STREAM_INLINE_AI")[0]; + h.runtime({ type: "INLINE_AI_START", requestId: request.requestId }); + h.action("dismiss"); + h.runtime({ type: "INLINE_AI_DONE", requestId: request.requestId, rawText: "Late answer" }); + expect(h.latest().view.collapsed).toBe(true); + h.action("reopen"); + expect(h.latest().view.title).toBe("How do you want to start?"); +}); + +it("preserves an approach draft when collapsed", async () => { + const h = await boot(); + h.action("expand_plan"); + h.action("dismiss", "Use a heap"); + h.action("reopen"); + expect(h.latest().view.inputValue).toBe("Use a heap"); +}); + +it("ignores a stale request acknowledgement after hide and reopen", async () => { + const h = await boot(); + const original = h.sendMessage.getMockImplementation(); + let acknowledge; + h.sendMessage.mockImplementation((message, callback) => { + if (message.type === "STREAM_INLINE_AI") acknowledge = callback; + else original(message, callback); + }); + h.action("hint"); + await vi.advanceTimersByTimeAsync(0); + h.action("dismiss"); + h.action("reopen"); + acknowledge({ ok: false, error: "Stale failure" }); + await vi.advanceTimersByTimeAsync(0); + expect(h.latest().view.title).toBe("How do you want to start?"); +}); diff --git a/src/content/inline-nudge.js b/src/content/inline-nudge.js index a5cfa92..260983f 100644 --- a/src/content/inline-nudge.js +++ b/src/content/inline-nudge.js @@ -25,6 +25,8 @@ let dismissedReasons = new Set(); let shownReasons = new Set(); let hiddenForProblem = false; + let lastView = null; + let hiddenReason = ""; let uiLanguage = "en"; let editorActivity = { editorType: "", focused: false, cursorLine: 1, lineCount: 1, lastChangeAt: 0 }; let inlineToken = ""; @@ -108,6 +110,10 @@ hideInline(); return; } + if (hiddenForProblem) { + if (!inlineToken) renderCollapsed(); + return; + } const result = context.testResults || {}; const signature = quickSignature(context.code || ""); @@ -195,11 +201,27 @@ } function handleInlineAction(action, value) { + if (action === "reopen") { + if (!enabled || !hiddenForProblem) return; + hiddenForProblem = false; + activeReason = hiddenReason || "planning"; + activeReasonKey = activeReason; + if (lastView) renderState(lastView); + else { + currentLevel = 1; + renderPrompt("planning"); + } + return; + } if (action === "dismiss") { + hiddenReason = activeReason; dismissedReasons.add(activeReasonKey || activeReason); + if (activeRequestId || lastView?.title === t("starting")) lastView = null; + if (lastView?.showInput) lastView = { ...lastView, inputValue: value }; hiddenForProblem = true; activeRequestId = ""; hideInline(); + renderCollapsed(); return; } if (action === "expand_plan") { @@ -244,12 +266,13 @@ } async function requestHint(level) { + const token = inlineToken; const context = await freshContext(); - if (!context || hiddenForProblem) return; - activeRequestId = crypto.randomUUID(); + if (!context || hiddenForProblem || token !== inlineToken) return; + const requestId = activeRequestId = crypto.randomUUID(); const response = await sendMessage({ type: "STREAM_INLINE_AI", - requestId: activeRequestId, + requestId, kind: "hint", hintLevel: Math.min(3, Math.max(1, level)), context: { @@ -262,10 +285,7 @@ : context.userNote || "" } }).catch((error) => ({ ok: false, error: error.message })); - if (hiddenForProblem) { - activeRequestId = ""; - return; - } + if (activeRequestId !== requestId) return; if (!response?.ok) { activeRequestId = ""; if (response?.code === "GUEST_NOT_STARTED") { @@ -277,21 +297,19 @@ } async function requestApproachFeedback(userMessage) { + const token = inlineToken; const context = await freshContext(); - if (!context || hiddenForProblem) return; - activeRequestId = crypto.randomUUID(); + if (!context || hiddenForProblem || token !== inlineToken) return; + const requestId = activeRequestId = crypto.randomUUID(); const response = await sendMessage({ type: "STREAM_INLINE_AI", - requestId: activeRequestId, + requestId, kind: "chat_coach", userMessage, chatHistory: [], context: { ...context, code: String(context.code || "").trim().length > 80 ? context.code : "" } }).catch((error) => ({ ok: false, error: error.message })); - if (hiddenForProblem) { - activeRequestId = ""; - return; - } + if (activeRequestId !== requestId) return; if (!response?.ok) { activeRequestId = ""; if (response?.code === "GUEST_NOT_STARTED") { @@ -317,8 +335,9 @@ async function startGuestThenHint() { renderState({ title: t("starting"), secondaryAction: "dismiss", secondaryLabel: t("hide") }); + const token = inlineToken; const response = await sendMessage({ type: "START_GUEST_TRIAL" }).catch((error) => ({ ok: false, error: error.message })); - if (hiddenForProblem) return; + if (hiddenForProblem || token !== inlineToken) return; if (!response?.ok) { renderError(response?.error || t("guestUnavailable")); return; @@ -345,6 +364,7 @@ function renderState(view) { if (hiddenForProblem) return; + lastView = view; inlineToken ||= crypto.randomUUID(); window.postMessage({ source: SOURCE_RENDER, @@ -361,6 +381,17 @@ activeReasonKey = ""; } + function renderCollapsed() { + if (!enabled || !activeContext?.allowed || !editorActivity.editorType) return; + inlineToken ||= crypto.randomUUID(); + window.postMessage({ + source: SOURCE_RENDER, + token: inlineToken, + lineNumber: Number.MAX_SAFE_INTEGER, + view: { collapsed: true, primaryAction: "reopen", primaryLabel: t("showCoach") } + }, "*"); + } + async function refreshSettings() { const response = await sendMessage({ type: "GET_SETTINGS" }).catch(() => null); if (!response?.ok) return; @@ -408,6 +439,8 @@ dismissedReasons = new Set(); shownReasons = new Set(); hiddenForProblem = false; + lastView = null; + hiddenReason = ""; hideInline(); } @@ -455,6 +488,7 @@ const STRINGS = { en: { + showCoach: "Show coach", planningTitle: "How do you want to start?", planningCopy: "Explain it in one or two sentences before you code.", planningPlaceholder: "I'll start by...", writeApproach: "Write approach", needHint: "Need a hint", checkApproach: "Check approach", hide: "Hide", failedTitle: "That run failed.", failedCopy: "Want one debugging question, not the answer?", debugThis: "Debug", closeTitle: "Samples pass.", closeCopy: "Check one edge case before submitting?", checkEdges: "Check edge case", @@ -464,6 +498,7 @@ guestLeft: "Guest · {remaining} left", connectAccess: "Start guest mode or connect your OpenAI API key.", guestUnavailable: "Guest mode is temporarily unavailable.", hintError: "CodeCoach could not generate a hint.", fullSolutionBlocked: "Full solution hidden. Ask for a smaller hint instead." }, ko: { + showCoach: "코치 다시 보기", planningTitle: "어떻게 시작할까요?", planningCopy: "코드를 더 작성하기 전에 한두 문장으로 적어보세요.", planningPlaceholder: "먼저 이렇게 생각해볼게요...", writeApproach: "접근 적기", needHint: "힌트 필요", checkApproach: "접근 확인", hide: "숨기기", failedTitle: "방금 실행이 실패했습니다.", failedCopy: "정답 말고 디버깅 질문 하나만 볼까요?", debugThis: "디버그", closeTitle: "샘플 테스트는 통과했습니다.", closeCopy: "제출 전에 엣지 케이스 하나 확인할까요?", checkEdges: "엣지 케이스 확인", diff --git a/src/sidepanel/guest-onboarding.js b/src/sidepanel/guest-onboarding.js index 33250db..50ada68 100644 --- a/src/sidepanel/guest-onboarding.js +++ b/src/sidepanel/guest-onboarding.js @@ -58,7 +58,7 @@ function installGuestChoice() { const style = document.createElement("style"); style.textContent = ` - .codecoach-guest-button{align-self:center;border:0;border-radius:4px;padding:7px 5px;background:transparent;color:var(--muted-strong);cursor:pointer;font:inherit;font-size:12px;text-decoration:underline;text-underline-offset:3px}.codecoach-guest-button:hover{color:var(--text)}.codecoach-guest-button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.codecoach-guest-button:disabled{opacity:.55;cursor:default}.codecoach-guest-note{margin:0;text-align:center;color:var(--muted);font-size:11px}.codecoach-guest-status{display:inline-flex;align-items:center;gap:6px;border:1px solid rgba(97,40,255,.18);background:var(--accent-soft);color:var(--accent-text);border-radius:999px;padding:4px 8px;font-size:11px;font-weight:650;margin-left:auto;white-space:nowrap}.codecoach-guest-status.is-empty{color:var(--warn);background:var(--warn-bg);border-color:var(--warn-border)} + .codecoach-guest-button{align-self:center;border:0;border-radius:4px;padding:7px 5px;background:transparent;color:var(--muted-strong);cursor:pointer;font:inherit;font-size:12px;text-decoration:underline;text-underline-offset:3px}.codecoach-guest-button:hover{color:var(--text)}.codecoach-guest-button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.codecoach-guest-button:disabled{opacity:.55;cursor:default}.codecoach-guest-note{margin:0;text-align:center;color:var(--muted);font-size:11px}.codecoach-guest-status{display:inline-block;border:0;background:transparent;color:var(--muted);border-radius:0;padding:4px 0;font-size:11px;font-weight:400;margin-left:auto;white-space:nowrap}.codecoach-guest-status.is-empty{color:var(--warn)} `; document.head.appendChild(style); saveView.append(button, note); @@ -163,7 +163,7 @@ const STRINGS = { continueGuest: "Try free first · 10 questions", startingGuest: "Starting...", guestUnavailable: "Guest mode is temporarily unavailable.", - guestLeft: "Guest · {remaining} left", + guestLeft: "{remaining} left", guestUsedBadge: "Guest trial used · add API key", guestUsed: "Free questions used", guestUsedNote: "Guest trial used. Connect your OpenAI API key to continue." @@ -172,7 +172,7 @@ const STRINGS = { continueGuest: "먼저 무료로 사용하기 · 10회", startingGuest: "시작 중...", guestUnavailable: "게스트 모드를 잠시 사용할 수 없습니다.", - guestLeft: "게스트 · {remaining}회 남음", + guestLeft: "{remaining}회 남음", guestUsedBadge: "게스트 체험 종료 · API key 연결", guestUsed: "무료 질문 사용 완료", guestUsedNote: "게스트 체험을 모두 사용했습니다. 계속하려면 OpenAI API key를 연결하세요." diff --git a/src/sidepanel/sidepanel.chat.js b/src/sidepanel/sidepanel.chat.js index bf53347..07b70ff 100644 --- a/src/sidepanel/sidepanel.chat.js +++ b/src/sidepanel/sidepanel.chat.js @@ -44,17 +44,13 @@ export function createChatController({ elements, documentRef = document, t = (ke return activeAssistantMessage; } - function setEmptyState({ apiConnected = false, aiAccessMode = "", allowed = false } = {}) { + function setEmptyState({ allowed = false } = {}) { if (!elements.coachChatTranscript || hasConversationContent()) return; - const apiText = aiAccessMode === "guest" - ? t("guestAiReady") - : (apiConnected || aiAccessMode === "byok") ? t("apiConnectedLabel") : t("apiNotConnectedLabel"); const pageText = allowed ? t("pageAllowedText") : t("pageNotAllowedText"); const existing = elements.coachChatTranscript.querySelector(".chat-empty-state"); const emptyState = existing || documentRef.createElement("div"); emptyState.className = "chat-empty-state"; emptyState.innerHTML = ` - ${escapeHtml(apiText)} ${escapeHtml(pageText)} `; if (!existing) elements.coachChatTranscript.appendChild(emptyState); diff --git a/src/sidepanel/sidepanel.chat.test.js b/src/sidepanel/sidepanel.chat.test.js new file mode 100644 index 0000000..d091a2d --- /dev/null +++ b/src/sidepanel/sidepanel.chat.test.js @@ -0,0 +1,48 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { createChatController } from "./sidepanel.chat.js"; + +afterEach(() => { + document.body.innerHTML = ""; + document.head.querySelectorAll("style").forEach((style) => style.remove()); + vi.unstubAllGlobals(); +}); + +it("shows only the page guidance, regardless of API access, until chatting starts", () => { + const transcript = document.createElement("div"); + const chat = createChatController({ + elements: { coachChatTranscript: transcript }, + t: (key) => key === "pageAllowedText" ? "Ask for a hint based on this problem." : key + }); + for (const aiAccessMode of ["none", "guest", "byok"]) { + chat.setEmptyState({ allowed: true, aiAccessMode }); + expect(transcript.textContent.trim()).toBe("Ask for a hint based on this problem."); + expect(transcript.querySelector("strong")).toBeNull(); + expect(transcript.querySelectorAll(".chat-empty-state")).toHaveLength(1); + } + chat.setEmptyState({ allowed: false }); + expect(transcript.textContent.trim()).toBe("pageNotAllowedText"); + chat.appendUserMessage("Help me"); + chat.setEmptyState({ allowed: true }); + expect(transcript.querySelector(".chat-empty-state")).toBeNull(); +}); + +it.each([["en", "10 left"], ["ko", "10회 남음"]])("renders plain remaining-count text in %s", async (uiLanguage, expected) => { + vi.resetModules(); + document.body.innerHTML = '
'; + vi.stubGlobal("chrome", { + runtime: { + onMessage: { addListener: vi.fn() }, + sendMessage: vi.fn(async ({ type }) => type === "GET_SETTINGS" + ? { settings: { uiLanguage, hasApiKey: false } } + : { enabled: true, trial: { remaining: 10 } }) + }, + storage: { onChanged: { addListener: vi.fn() } } + }); + await import("./guest-onboarding.js"); + await vi.waitFor(() => { + const status = document.getElementById("codecoach-guest-status"); + expect(status?.textContent).toBe(expected); + expect(getComputedStyle(status).backgroundColor).toBe("rgba(0, 0, 0, 0)"); + expect(getComputedStyle(status).borderRadius).toBe("0px"); + }); +});