From a477a553c76345ecc440dc327e64c51e0454e267 Mon Sep 17 00:00:00 2001 From: tkim01 Date: Sat, 5 Sep 2026 12:13:14 +0900 Subject: [PATCH 1/2] release: prepare 1.2.0 privacy disclosures and quiet chat status --- README.md | 8 +++- assets/privacy-policy.html | 65 ++++++++++++++++++++++++---- docs/privacy-policy.html | 65 ++++++++++++++++++++++++---- landing/extension-policy.test.ts | 19 ++++++++ landing/public/privacy-policy.html | 65 ++++++++++++++++++++++++---- package-lock.json | 4 +- package.json | 2 +- src/sidepanel/guest-onboarding.js | 6 +-- src/sidepanel/sidepanel.chat.js | 6 +-- src/sidepanel/sidepanel.chat.test.js | 48 ++++++++++++++++++++ 10 files changed, 252 insertions(+), 36 deletions(-) create mode 100644 src/sidepanel/sidepanel.chat.test.js 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..e68b365 100644 --- a/assets/privacy-policy.html +++ b/assets/privacy-policy.html @@ -97,7 +97,8 @@

Privacy Policy

CodeCoach — Chrome Extension
- Last updated: 2026-09-03 + Version: 1.2.0
+ Last updated: 2026-09-05

@@ -177,7 +178,8 @@

3.1 Information stored locally in your browser

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.

3.2 Information used for AI requests

@@ -190,6 +192,8 @@

3.2 Information used for AI requests

@@ -280,6 +284,27 @@

3.5 Optional cloud sync information

and requests the required permission or consent.

+

3.6 Guest quota and operational records

+

+ 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. +

+

4. Information CodeCoach does not collect

CodeCoach does not collect or store the following as developer-controlled data: @@ -521,11 +546,11 @@

12. Data retention

Optional Firebase cloud sync learning metadata - Until the user disables sync, deletes synced data where supported, requests deletion, or closes the account + Until deleted through supported controls or a deletion request. Disabling sync stops future synchronization but does not itself delete previously synchronized data. OpenAI API request data and guest quota records - OpenAI request data is handled according to OpenAI's applicable policies. Guest quota records are retained as needed to enforce free-use limits, abuse protection, cost controls, and legal or security requirements. + OpenAI request data is handled according to OpenAI's applicable policies. Guest quota records are retained for free-use limits, abuse protection, and cost controls; the current application does not implement automatic expiry of these records. Contact the developer to request deletion. Operational logs follow the configured Firebase / Google retention settings and applicable provider policies. @@ -544,6 +569,12 @@

13. Data deletion

14. User access, correction, deletion, and objection requests

+

+ 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,19 @@

16. Data processing agreements and subprocessors

CodeCoach operates a Firebase backend for guest AI coaching. The guest backend validates structured coaching requests, enforces quota and cost limits, and forwards allowed requests to OpenAI. BYOK OpenAI requests continue to go directly from the extension to OpenAI. - If a future version adds persistent server logs, analytics SDKs, or additional processors, - this policy will be updated before or at the time those changes are released. + Guest quota records and operational logging are described in Section 3.6. + If a future version adds analytics SDKs or additional processors, this policy will be + updated before or at the time those changes are released.

17. AI feature disclosure

+

+ 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. You can dismiss the inline coach using Hide. +

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 +638,17 @@

17. AI feature disclosure

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..e68b365 100644 --- a/docs/privacy-policy.html +++ b/docs/privacy-policy.html @@ -97,7 +97,8 @@

Privacy Policy

CodeCoach — Chrome Extension
- Last updated: 2026-09-03 + Version: 1.2.0
+ Last updated: 2026-09-05

@@ -177,7 +178,8 @@

3.1 Information stored locally in your browser

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.

3.2 Information used for AI requests

@@ -190,6 +192,8 @@

3.2 Information used for AI requests

@@ -280,6 +284,27 @@

3.5 Optional cloud sync information

and requests the required permission or consent.

+

3.6 Guest quota and operational records

+

+ 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. +

+

4. Information CodeCoach does not collect

CodeCoach does not collect or store the following as developer-controlled data: @@ -521,11 +546,11 @@

12. Data retention

Optional Firebase cloud sync learning metadata - Until the user disables sync, deletes synced data where supported, requests deletion, or closes the account + Until deleted through supported controls or a deletion request. Disabling sync stops future synchronization but does not itself delete previously synchronized data. OpenAI API request data and guest quota records - OpenAI request data is handled according to OpenAI's applicable policies. Guest quota records are retained as needed to enforce free-use limits, abuse protection, cost controls, and legal or security requirements. + OpenAI request data is handled according to OpenAI's applicable policies. Guest quota records are retained for free-use limits, abuse protection, and cost controls; the current application does not implement automatic expiry of these records. Contact the developer to request deletion. Operational logs follow the configured Firebase / Google retention settings and applicable provider policies. @@ -544,6 +569,12 @@

13. Data deletion

14. User access, correction, deletion, and objection requests

+

+ 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,19 @@

16. Data processing agreements and subprocessors

CodeCoach operates a Firebase backend for guest AI coaching. The guest backend validates structured coaching requests, enforces quota and cost limits, and forwards allowed requests to OpenAI. BYOK OpenAI requests continue to go directly from the extension to OpenAI. - If a future version adds persistent server logs, analytics SDKs, or additional processors, - this policy will be updated before or at the time those changes are released. + Guest quota records and operational logging are described in Section 3.6. + If a future version adds analytics SDKs or additional processors, this policy will be + updated before or at the time those changes are released.

17. AI feature disclosure

+

+ 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. You can dismiss the inline coach using Hide. +

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 +638,17 @@

17. AI feature disclosure

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..e68b365 100644 --- a/landing/public/privacy-policy.html +++ b/landing/public/privacy-policy.html @@ -97,7 +97,8 @@

Privacy Policy

CodeCoach — Chrome Extension
- Last updated: 2026-09-03 + Version: 1.2.0
+ Last updated: 2026-09-05

@@ -177,7 +178,8 @@

3.1 Information stored locally in your browser

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.

3.2 Information used for AI requests

@@ -190,6 +192,8 @@

3.2 Information used for AI requests

@@ -280,6 +284,27 @@

3.5 Optional cloud sync information

and requests the required permission or consent.

+

3.6 Guest quota and operational records

+

+ 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. +

+

4. Information CodeCoach does not collect

CodeCoach does not collect or store the following as developer-controlled data: @@ -521,11 +546,11 @@

12. Data retention

Optional Firebase cloud sync learning metadata - Until the user disables sync, deletes synced data where supported, requests deletion, or closes the account + Until deleted through supported controls or a deletion request. Disabling sync stops future synchronization but does not itself delete previously synchronized data. OpenAI API request data and guest quota records - OpenAI request data is handled according to OpenAI's applicable policies. Guest quota records are retained as needed to enforce free-use limits, abuse protection, cost controls, and legal or security requirements. + OpenAI request data is handled according to OpenAI's applicable policies. Guest quota records are retained for free-use limits, abuse protection, and cost controls; the current application does not implement automatic expiry of these records. Contact the developer to request deletion. Operational logs follow the configured Firebase / Google retention settings and applicable provider policies. @@ -544,6 +569,12 @@

13. Data deletion

14. User access, correction, deletion, and objection requests

+

+ 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,19 @@

16. Data processing agreements and subprocessors

CodeCoach operates a Firebase backend for guest AI coaching. The guest backend validates structured coaching requests, enforces quota and cost limits, and forwards allowed requests to OpenAI. BYOK OpenAI requests continue to go directly from the extension to OpenAI. - If a future version adds persistent server logs, analytics SDKs, or additional processors, - this policy will be updated before or at the time those changes are released. + Guest quota records and operational logging are described in Section 3.6. + If a future version adds analytics SDKs or additional processors, this policy will be + updated before or at the time those changes are released.

17. AI feature disclosure

+

+ 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. You can dismiss the inline coach using Hide. +

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 +638,17 @@

17. AI feature disclosure

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/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"); + }); +}); From 17eb0b2ab65b470c901d173eb643aa4dd98396d0 Mon Sep 17 00:00:00 2001 From: tkim01 Date: Sat, 5 Sep 2026 12:18:25 +0900 Subject: [PATCH 2/2] fix: allow reopening hidden inline coaching without another request --- assets/privacy-policy.html | 4 +- docs/privacy-policy.html | 4 +- landing/public/privacy-policy.html | 4 +- src/content/inline-nudge-bridge.js | 7 +- src/content/inline-nudge-bridge.test.js | 27 +++++++ src/content/inline-nudge-reopen.test.js | 97 +++++++++++++++++++++++++ src/content/inline-nudge.js | 65 +++++++++++++---- 7 files changed, 188 insertions(+), 20 deletions(-) create mode 100644 src/content/inline-nudge-reopen.test.js diff --git a/assets/privacy-policy.html b/assets/privacy-policy.html index e68b365..68c128c 100644 --- a/assets/privacy-policy.html +++ b/assets/privacy-policy.html @@ -627,7 +627,9 @@

17. AI feature disclosure

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. You can dismiss the inline coach using Hide. + 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. diff --git a/docs/privacy-policy.html b/docs/privacy-policy.html index e68b365..68c128c 100644 --- a/docs/privacy-policy.html +++ b/docs/privacy-policy.html @@ -627,7 +627,9 @@

17. AI feature disclosure

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. You can dismiss the inline coach using Hide. + 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. diff --git a/landing/public/privacy-policy.html b/landing/public/privacy-policy.html index e68b365..68c128c 100644 --- a/landing/public/privacy-policy.html +++ b/landing/public/privacy-policy.html @@ -627,7 +627,9 @@

17. AI feature disclosure

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. You can dismiss the inline coach using Hide. + 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. 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: "엣지 케이스 확인",