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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/superpowers/specs/2026-09-05-proactive-entry-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ Show CodeCoach as soon as a supported problem editor is ready, without revealing

- Show one opening prompt about 1.5 seconds after the problem and editor are detected.
- Ask `How do you want to start?` / `어떻게 시작할까요?`.
- Offer `Write approach`, `Need a hint`, and `Not now` actions.
- Offer `Write approach`, `Need a hint`, and `Hide` actions.
- Keep the opening prompt to once per problem.
- Render coach output on the virtual line after the final code line, matching that line's indentation.
- Keep the inline coach visible through code edits and submission results until the learner selects `Hide` / `숨기기`.
- Keep `Thinking…` visible during streaming and render the completed answer once.
- Show stuck coaching after 45 seconds and allow non-failure follow-ups after a 2-minute cooldown.
- Keep the existing three-intervention limit and solution-code blocking.
Expand Down
66 changes: 38 additions & 28 deletions src/content/inline-nudge.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
let lastCloseEventId = "";
let dismissedReasons = new Set();
let shownReasons = new Set();
let hiddenForProblem = false;
let uiLanguage = "en";
let editorActivity = { editorType: "", focused: false, cursorLine: 1, lineCount: 1, lastChangeAt: 0 };
let inlineToken = "";
Expand All @@ -50,7 +51,7 @@
chrome.runtime.onMessage.addListener((message) => {
if (!activeRequestId || message?.requestId !== activeRequestId) return false;
if (message.type === "INLINE_AI_START") {
renderState({ title: t("thinking"), tone: "neutral" });
renderState({ title: t("thinking"), tone: "neutral", secondaryAction: "dismiss", secondaryLabel: t("hide") });
return false;
}
if (message.type === "INLINE_AI_DELTA") return false;
Expand Down Expand Up @@ -111,15 +112,13 @@
const result = context.testResults || {};
const signature = quickSignature(context.code || "");
if (result.status === "passed" && result.kind === "submit") {
hideInline();
shownReasons.add("solved");
return;
}

if (signature && signature !== lastCodeSignature) {
if (lastCodeSignature) {
lastProgressAt = Math.max(Date.now(), editorActivity.lastChangeAt || 0);
if (isPassiveReason(activeReason)) hideInline();
}
lastCodeSignature = signature;
}
Expand Down Expand Up @@ -158,7 +157,7 @@

function maybeShow(reason, context, eventId = "") {
const reasonKey = eventId ? `${reason}:${eventId}` : reason;
if (activeReason || dismissedReasons.has(reasonKey) || shownReasons.has(reasonKey)) return;
if (hiddenForProblem || activeReason || dismissedReasons.has(reasonKey) || shownReasons.has(reasonKey)) return;
if (interventions >= MAX_INTERVENTIONS_PER_PROBLEM) return;
if (Date.now() - lastNudgeAt < NUDGE_COOLDOWN_MS && reason !== "failed") return;
activeReason = reason;
Expand All @@ -180,24 +179,26 @@
secondaryAction: "hint",
secondaryLabel: t("needHint"),
tertiaryAction: "dismiss",
tertiaryLabel: t("notNow")
tertiaryLabel: t("hide")
});
return;
}
if (reason === "failed") {
renderState({ title: t("failedTitle"), body: t("failedCopy"), primaryAction: "hint", primaryLabel: t("debugThis"), secondaryAction: "dismiss", secondaryLabel: t("dismiss") });
renderState({ title: t("failedTitle"), body: t("failedCopy"), primaryAction: "hint", primaryLabel: t("debugThis"), secondaryAction: "dismiss", secondaryLabel: t("hide") });
return;
}
if (reason === "close") {
renderState({ title: t("closeTitle"), body: t("closeCopy"), tone: "success", primaryAction: "hint", primaryLabel: t("checkEdges"), secondaryAction: "dismiss", secondaryLabel: t("dismiss") });
renderState({ title: t("closeTitle"), body: t("closeCopy"), tone: "success", primaryAction: "hint", primaryLabel: t("checkEdges"), secondaryAction: "dismiss", secondaryLabel: t("hide") });
return;
}
renderState({ title: t("stuckTitle"), body: t("stuckCopy"), primaryAction: "hint", primaryLabel: t("showHint"), secondaryAction: "dismiss", secondaryLabel: t("dismiss") });
renderState({ title: t("stuckTitle"), body: t("stuckCopy"), primaryAction: "hint", primaryLabel: t("showHint"), secondaryAction: "dismiss", secondaryLabel: t("hide") });
}

function handleInlineAction(action, value) {
if (action === "dismiss" || action === "done") {
if (action === "dismiss") dismissedReasons.add(activeReasonKey || activeReason);
if (action === "dismiss") {
dismissedReasons.add(activeReasonKey || activeReason);
hiddenForProblem = true;
activeRequestId = "";
hideInline();
return;
}
Expand All @@ -210,7 +211,7 @@
primaryAction: "check_approach",
primaryLabel: t("checkApproach"),
secondaryAction: "dismiss",
secondaryLabel: t("notNow")
secondaryLabel: t("hide")
});
return;
}
Expand Down Expand Up @@ -244,7 +245,7 @@

async function requestHint(level) {
const context = await freshContext();
if (!context) return;
if (!context || hiddenForProblem) return;
activeRequestId = crypto.randomUUID();
const response = await sendMessage({
type: "STREAM_INLINE_AI",
Expand All @@ -261,6 +262,10 @@
: context.userNote || ""
}
}).catch((error) => ({ ok: false, error: error.message }));
if (hiddenForProblem) {
activeRequestId = "";
return;
}
if (!response?.ok) {
activeRequestId = "";
if (response?.code === "GUEST_NOT_STARTED") {
Expand All @@ -273,7 +278,7 @@

async function requestApproachFeedback(userMessage) {
const context = await freshContext();
if (!context) return;
if (!context || hiddenForProblem) return;
activeRequestId = crypto.randomUUID();
const response = await sendMessage({
type: "STREAM_INLINE_AI",
Expand All @@ -283,6 +288,10 @@
chatHistory: [],
context: { ...context, code: String(context.code || "").trim().length > 80 ? context.code : "" }
}).catch((error) => ({ ok: false, error: error.message }));
if (hiddenForProblem) {
activeRequestId = "";
return;
}
if (!response?.ok) {
activeRequestId = "";
if (response?.code === "GUEST_NOT_STARTED") {
Expand All @@ -300,13 +309,16 @@
primaryAction: "start_guest",
primaryLabel: t("tryFreeCta"),
secondaryAction: "settings",
secondaryLabel: t("settings")
secondaryLabel: t("settings"),
tertiaryAction: "dismiss",
tertiaryLabel: t("hide")
});
}

async function startGuestThenHint() {
renderState({ title: t("starting") });
renderState({ title: t("starting"), secondaryAction: "dismiss", secondaryLabel: t("hide") });
const response = await sendMessage({ type: "START_GUEST_TRIAL" }).catch((error) => ({ ok: false, error: error.message }));
if (hiddenForProblem) return;
if (!response?.ok) {
renderError(response?.error || t("guestUnavailable"));
return;
Expand All @@ -321,17 +333,18 @@
title: text || t("thinking"),
primaryAction: done ? primaryAction : "",
primaryLabel: done ? primaryLabel : "",
secondaryAction: done ? "done" : "",
secondaryLabel: done ? t("done") : "",
secondaryAction: done ? "dismiss" : "",
secondaryLabel: done ? t("hide") : "",
trialText: trial && Number.isFinite(Number(trial.remaining)) ? t("guestLeft", { remaining: trial.remaining }) : ""
});
}

function renderError(text) {
renderState({ title: text, secondaryAction: "done", secondaryLabel: t("close") });
renderState({ title: text, secondaryAction: "dismiss", secondaryLabel: t("hide") });
}

function renderState(view) {
if (hiddenForProblem) return;
inlineToken ||= crypto.randomUUID();
window.postMessage({
source: SOURCE_RENDER,
Expand Down Expand Up @@ -394,13 +407,10 @@
interventions = 0;
dismissedReasons = new Set();
shownReasons = new Set();
hiddenForProblem = false;
hideInline();
}

function isPassiveReason(reason) {
return ["planning", "stuck", "failed", "close"].includes(reason);
}

function quickSignature(text) {
const value = String(text || "");
let hash = 2166136261;
Expand Down Expand Up @@ -445,21 +455,21 @@

const STRINGS = {
en: {
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", notNow: "Not now",
failedTitle: "That run failed.", failedCopy: "Want one debugging question, not the answer?", debugThis: "Debug", dismiss: "Dismiss",
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",
stuckTitle: "Need a nudge?", stuckCopy: "One next thought, without the solution.", showHint: "Show",
tryFreeTitle: "Start without an API key", tryFreeCopy: "10 free questions are available.", tryFreeCta: "Start free", settings: "Settings",
thinking: "Thinking…", starting: "Starting…", moreSpecific: "More specific", openCoach: "Open coach", done: "Done", close: "Close",
thinking: "Thinking…", starting: "Starting…", moreSpecific: "More specific", openCoach: "Open coach",
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: {
planningTitle: "어떻게 시작할까요?", planningCopy: "코드를 더 작성하기 전에 한두 문장으로 적어보세요.", planningPlaceholder: "먼저 이렇게 생각해볼게요...", writeApproach: "접근 적기", needHint: "힌트 필요", checkApproach: "접근 확인", notNow: "나중에",
failedTitle: "방금 실행이 실패했습니다.", failedCopy: "정답 말고 디버깅 질문 하나만 볼까요?", debugThis: "디버그", dismiss: "닫기",
planningTitle: "어떻게 시작할까요?", planningCopy: "코드를 더 작성하기 전에 한두 문장으로 적어보세요.", planningPlaceholder: "먼저 이렇게 생각해볼게요...", writeApproach: "접근 적기", needHint: "힌트 필요", checkApproach: "접근 확인", hide: "숨기기",
failedTitle: "방금 실행이 실패했습니다.", failedCopy: "정답 말고 디버깅 질문 하나만 볼까요?", debugThis: "디버그",
closeTitle: "샘플 테스트는 통과했습니다.", closeCopy: "제출 전에 엣지 케이스 하나 확인할까요?", checkEdges: "엣지 케이스 확인",
stuckTitle: "작은 힌트가 필요하신가요?", stuckCopy: "정답 없이 다음 생각 하나만 짚어드릴게요.", showHint: "보기",
tryFreeTitle: "API key 없이 시작", tryFreeCopy: "무료 질문 10회를 사용할 수 있습니다.", tryFreeCta: "무료로 시작", settings: "설정",
thinking: "생각 중…", starting: "시작 중…", moreSpecific: "더 구체적으로", openCoach: "코치 열기", done: "완료", close: "닫기",
thinking: "생각 중…", starting: "시작 중…", moreSpecific: "더 구체적으로", openCoach: "코치 열기",
guestLeft: "게스트 · {remaining}회 남음", connectAccess: "게스트 모드를 시작하거나 OpenAI API key를 연결하세요.", guestUnavailable: "게스트 모드를 잠시 사용할 수 없습니다.", hintError: "CodeCoach가 힌트를 만들지 못했습니다.", fullSolutionBlocked: "전체 정답 코드는 숨겼습니다. 더 작은 힌트를 요청해 주세요."
}
};
Expand Down
12 changes: 12 additions & 0 deletions src/content/inline-nudge.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,18 @@ describe("inline coach localization", () => {
expect(source).toContain('if (message.type === "INLINE_AI_DELTA") return false;');
});

it("stays visible until the learner explicitly hides it", () => {
expect(source).toContain('hide: "Hide"');
expect(source).toContain('hide: "숨기기"');
expect(source).toContain('renderState({ title: t("thinking"), tone: "neutral", secondaryAction: "dismiss", secondaryLabel: t("hide") });');
expect(source).not.toContain("if (isPassiveReason(activeReason)) hideInline();");
expect(source).not.toContain("function isPassiveReason");
expect(source).not.toContain('tertiaryLabel: t("notNow")');
expect(source).toContain("let hiddenForProblem = false;");
expect(source).toContain("if (hiddenForProblem || activeReason");
expect(source).toContain("hiddenForProblem = true;");
});

it("refreshes settings through the extension-level storage change event", async () => {
vi.useFakeTimers();
const storageListener = vi.fn();
Expand Down
Loading