diff --git a/apps/api/src/gemini.ts b/apps/api/src/gemini.ts index 2f4182b..d288258 100644 --- a/apps/api/src/gemini.ts +++ b/apps/api/src/gemini.ts @@ -280,9 +280,9 @@ export async function rewriteForDims(args: { target_dims: Dimension[]; file_path?: string; team_context?: string; -}): Promise<{ rewritten_prompt: string }> { +}): Promise<{ rewritten_prompt: string; tip: string }> { if (args.target_dims.length === 0) { - return { rewritten_prompt: args.prompt }; + return { rewritten_prompt: args.prompt, tip: '' }; } const systemInstruction = args.team_context @@ -303,30 +303,190 @@ export async function rewriteForDims(args: { systemInstruction, temperature: 0.3, thinkingConfig: { thinkingBudget: -1 }, - maxOutputTokens: 400, + // Bumped 400 → 500 to make headroom for the new `tip` field + // without crowding the rewrite. Schema enforcement still bounds + // the worst case; the tip is hard-capped at 100 chars in-prompt. + maxOutputTokens: 500, responseMimeType: 'application/json', responseSchema: { type: Type.OBJECT, - required: ['rewritten_prompt'], + required: ['rewritten_prompt', 'tip'], properties: { rewritten_prompt: { type: Type.STRING }, + // maxLength stringified per the SDK contract — see the note + // on `missing` in the score schema for the wire bug this + // works around. + tip: { type: Type.STRING, maxLength: '120' }, }, }, }, }), 'teach-rewrite', ); - const parsed = tryParseJson<{ rewritten_prompt?: string }>(extractAnswer(resp)); + const parsed = tryParseJson<{ rewritten_prompt?: string; tip?: string }>(extractAnswer(resp)); const rewritten = typeof parsed?.rewritten_prompt === 'string' ? parsed.rewritten_prompt.trim() : ''; - // Empty string → caller falls back to a hardcoded line. We don't throw - // because /coach is the never-block path: render the block without an - // example rather than blowing up the whole coaching turn. - return { rewritten_prompt: rewritten }; + const tip = typeof parsed?.tip === 'string' ? parsed.tip.trim() : ''; + // Empty strings → callers fall back to the static template / no tip. + // We don't throw because /coach is the never-block path: render the + // block without an example or tip rather than blowing up the turn. + return { rewritten_prompt: rewritten, tip }; } catch (err) { console.warn('[gemini] teach-rewrite failed', err); - return { rewritten_prompt: '' }; + return { rewritten_prompt: '', tip: '' }; + } +} + +// ----- /coach round-2+ acknowledgment --------------------------------------- +// Gemini-generated one-liner that names the user's most recent edit and the +// dimension it lifted. Used to prepend recognition before the next teach +// block in the round 2+ "still <7, made progress, more rounds remain" +// branch. Fail-open: empty string → renderTeachBlock falls back to the +// existing static "You addressed X" line. +// +// Same guardrail discipline as the rest: Flash + responseSchema + +// thinkingBudget=-1 + maxOutputTokens cap + the no-retry-on-timeout policy. +const ACKNOWLEDGE_SYSTEM_PROMPT = `You acknowledge a developer's improvement to their prompt in ONE short sentence (under 120 characters). + +You receive: their previous prompt, their current prompt, the per-dimension scores before and after, and the list of dimensions whose scores increased. Name the concrete addition they made AND the dimension it lifted. Specific > generic. + +Return JSON only, no prose: +{ "acknowledgment": } + +Rules — MUST follow: +- ONE sentence, under 120 characters. No bullets, no list, no follow-up question. +- Reference the actual change (a file path, a constraint, an output shape they added) — do NOT just say "good progress". +- Mention the dimension that improved by name. +- Tone: warm, peer-to-peer. Avoid corporate phrasing. +- NEVER ask a question. NEVER use "What about..." / "How does...". +- If no dimension improved, return an empty string for "acknowledgment".`; + +export async function acknowledgeProgress(args: { + previous_prompt: string; + current_prompt: string; + previous_dimensions: DimensionScores; + current_dimensions: DimensionScores; +}): Promise { + // Compute the dim deltas client-side so the model gets a clean signal + // and can't hallucinate which dim moved. + const improved = DIMENSIONS + .map((d) => ({ d, delta: args.current_dimensions[d] - args.previous_dimensions[d] })) + .filter((x) => x.delta > 0) + .sort((a, b) => b.delta - a.delta); + if (improved.length === 0) return ''; + + const dimsLine = (s: DimensionScores) => + DIMENSIONS.map((d) => `${d}=${s[d]}`).join(', '); + + const userMessage = + `Previous prompt:\n${args.previous_prompt}\n\n` + + `Current prompt:\n${args.current_prompt}\n\n` + + `Previous scores: ${dimsLine(args.previous_dimensions)}\n` + + `Current scores: ${dimsLine(args.current_dimensions)}\n` + + `Dimensions that improved: ${improved.map((x) => `${x.d} (+${x.delta})`).join(', ')}`; + + try { + const resp = await withRetry( + () => ai.models.generateContent({ + model: SCORE_MODEL, + contents: userMessage, + config: { + systemInstruction: ACKNOWLEDGE_SYSTEM_PROMPT, + temperature: 0.3, + thinkingConfig: { thinkingBudget: -1 }, + maxOutputTokens: 200, + responseMimeType: 'application/json', + responseSchema: { + type: Type.OBJECT, + required: ['acknowledgment'], + properties: { + acknowledgment: { type: Type.STRING, maxLength: '140' }, + }, + }, + }, + }), + 'acknowledge', + ); + const parsed = tryParseJson<{ acknowledgment?: string }>(extractAnswer(resp)); + return typeof parsed?.acknowledgment === 'string' ? parsed.acknowledgment.trim() : ''; + } catch (err) { + console.warn('[gemini] acknowledge failed', err); + return ''; + } +} + +// ----- /coach end-of-session summary ---------------------------------------- +// Gemini-written closing recap appended to renderSuccessReveal / +// renderSkipReveal. Frames the arc as a mini-lesson: what improved, the +// principle the user practiced, one takeaway for next time. Used in success, +// no-progress, skip, and max-rounds forced-exit branches; tone adapts to +// `reason` so the no-progress / skip cases stay honest instead of +// celebrating something that didn't happen. +const SUMMARIZE_SYSTEM_PROMPT = `You write a short closing recap (3-4 sentences total) of a developer's prompt-coaching session. + +You receive: the original prompt, the final prompt, scores before/after, and the reason the session ended (success / max_rounds / no_progress / skip). Adapt the tone: +- success / max_rounds — celebrate the moves they made and name the prompt-engineering principle they practiced. +- no_progress / skip — acknowledge they bailed, but call out what they could have added; teach the principle they missed. + +Return JSON only, no prose: +{ "summary": } + +Rules — MUST follow: +- 3-4 sentences total. Hard cap: 600 characters. No bullets, no headers, no preamble like "Here's a recap:". +- Reference the actual content (file paths, constraints, output shapes) the user did or didn't add. Specific > generic. +- Name ONE prompt-engineering principle (e.g. "anchoring with file paths", "naming invariants up front", "specifying output shape"). Do not list more than one. +- End with ONE concrete takeaway for next time, phrased as a habit, not as a question. +- Tone: warm, peer-to-peer, like a senior dev recapping a session at the desk. +- NEVER ask a question. NEVER list alternatives. NEVER use "What if..." / "How could...".`; + +export async function summarizeCoaching(args: { + original_prompt: string; + final_prompt: string; + original_dimensions: DimensionScores; + final_dimensions: DimensionScores; + reason: 'success' | 'max_rounds' | 'no_progress' | 'skip'; +}): Promise { + const dimsLine = (s: DimensionScores) => + DIMENSIONS.map((d) => `${d}=${s[d]}`).join(', '); + + const userMessage = + `Reason: ${args.reason}\n\n` + + `Original prompt:\n${args.original_prompt}\n\n` + + `Final prompt:\n${args.final_prompt}\n\n` + + `Original scores: ${dimsLine(args.original_dimensions)}\n` + + `Final scores: ${dimsLine(args.final_dimensions)}`; + + try { + const resp = await withRetry( + () => ai.models.generateContent({ + model: SCORE_MODEL, + contents: userMessage, + config: { + systemInstruction: SUMMARIZE_SYSTEM_PROMPT, + temperature: 0.3, + thinkingConfig: { thinkingBudget: -1 }, + // 600-char hard cap in the prompt; 600 tokens is a generous + // ceiling that bounds runaway output without clipping a + // well-formed recap. + maxOutputTokens: 600, + responseMimeType: 'application/json', + responseSchema: { + type: Type.OBJECT, + required: ['summary'], + properties: { + summary: { type: Type.STRING, maxLength: '700' }, + }, + }, + }, + }), + 'summarize', + ); + const parsed = tryParseJson<{ summary?: string }>(extractAnswer(resp)); + return typeof parsed?.summary === 'string' ? parsed.summary.trim() : ''; + } catch (err) { + console.warn('[gemini] summarize failed', err); + return ''; } } @@ -377,8 +537,9 @@ export async function synthesizeDiff(args: { `Compare these two prompts on the five Trailhead dimensions.\n\n` + `USER (${dimsLine(args.user_scores)}):\n${args.user_prompt}\n\n` + `TEAM (${dimsLine(args.team_scores)}):\n${args.team_prompt}\n\n` + - `Write 2-3 sentences naming the specific dimensions the user fell short on ` + - `and what the team prompt did differently. No bullets, no preamble.`, + `In 2-3 sentences, name ONE prompt-engineering move the team prompt makes that the user's didn't, ` + + `then phrase how to apply that move next time as a habit (not a question). No bullets, no preamble, ` + + `no rhetorical "What if..." / "How could..." phrasing.`, config: { temperature: 0.2 }, }), 'diff', @@ -389,15 +550,31 @@ export async function synthesizeDiff(args: { // ----- /improve -------------------------------------------------------------- // Gemini-driven multi-turn prompt coach. Stateless — caller passes the full // conversation each turn. Spec: 2026-04-26-improve-widget-design.md -const IMPROVE_SYSTEM_PROMPT = `You are a senior engineer's prompt coach. The user is about to send a prompt to Claude. Your job is to ask one focused follow-up question that would meaningfully raise the prompt's quality on the listed weak dimensions, OR — if you already have enough information — return the polished prompt. - -Rules: -- One question per turn. Keep it concrete: file path, expected output shape, constraints, current code location. -- Stop asking once you have enough to write a strong final prompt. Don't pad the conversation. -- The polished prompt must preserve the user's original intent. Add specificity, do not invent requirements the user didn't imply. -- Output JSON matching the schema exactly. No prose outside the JSON. - -If the command is "finalize", you MUST return kind="final" regardless of how much information you have. Synthesize the best polished prompt you can from what's available.`; +const IMPROVE_SYSTEM_PROMPT = `You are a senior engineer's prompt coach with an educational mindset. The user is about to send a prompt to Claude. You have two jobs at once: +1) Help them produce a sharper prompt by asking targeted follow-up questions, OR — when you have enough context — return a polished version. +2) Teach them prompt-engineering principles along the way, so every conversation leaves them a better prompter. + +How to teach while asking: +- Lead every question with a one-sentence "Tip:" that names the prompt-engineering principle behind it. The tip must be specific to the weakness you are probing — not a generic platitude. + Examples: + - "Tip: Vague locations force Claude to guess, and it often guesses wrong. Which file or directory should it focus on?" + - "Tip: Without an explicit output shape, Claude picks one that may not fit your codebase. Should this be a single function, a class, a code snippet, or a diff against existing code?" + - "Tip: Constraints prevent over-engineering and keep edits surgical. Are there parts of the code you want left untouched, or libraries you do not want introduced?" + - "Tip: Examples ground abstract requests. Could you paste a small input/output sample, or describe one in concrete numbers?" + - "Tip: A clear success criterion lets Claude know when to stop iterating. How will you know the change worked — a passing test, a UI behavior, a metric?" +- Vary the tip across turns — do not repeat the same principle two questions in a row. +- Keep tip + question under 35 words combined. You are a teacher, not a lecturer. +- Tone: warm, concise, peer-to-peer. Avoid corporate phrasing like "Could you please clarify…". Sound like a senior dev nudging a junior across a desk. + +When to finalize: +- Stop asking once you have enough to write a strong polished prompt. Three or four questions is usually plenty; do not drag the conversation. +- The polished prompt must preserve the user's original intent. Weave in the specifics they gave you; do not invent requirements they did not imply. +- The "rationale" field is shown to the user as a "What changed" recap — treat it as a mini-lesson. Write 1-2 sentences naming the weak dimensions you addressed and the prompt-engineering moves you made (e.g. "Added an explicit file path and an expected diff shape so Claude does not have to guess location or output format."). + +Output rules: +- JSON only. No prose outside the JSON. Match the schema exactly. +- One question per turn when kind="question". +- If the command is "finalize", you MUST return kind="final" regardless of how much context you have. Synthesize the best polished prompt and rationale you can from what is available.`; export interface ImproveCoachInput { original_prompt: string; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 07eac77..a692f04 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -55,11 +55,13 @@ import { } from '@trailhead/scoring'; import { DEMO_TEAM_TOKEN, q, teamIdForToken, upsertNode, wipeTeamData } from './db.ts'; import { + acknowledgeProgress, extractTopic, improveCoach, overallScore, rewriteForDims, scorePrompt, + summarizeCoaching, synthesizeDiff, } from './gemini.ts'; import { renderTeamContext } from './team-context.ts'; @@ -238,7 +240,7 @@ app.post('/score', async (c) => { // next_round_inputs echoed back. Server enforces the round cap and bails // on no-progress. -const COACH_MAX_ROUNDS = 3; +const COACH_MAX_ROUNDS = 5; function clampRound(n: number | undefined): number { if (typeof n !== 'number' || !Number.isFinite(n)) return 1; @@ -303,17 +305,23 @@ async function fetchTopGraduatedForTeam(teamId: string): Promise // graduated team prompt is already strong on most dims and seeing it // teaches the user something either way. If the wiki has nothing, fall // back to a Gemini rewrite that explicitly targets the named dimensions. +// +// Returns the example string AND the optional tip — the wiki path has no +// tip (we just have the template), the Gemini fallback emits one alongside +// the rewrite. Callers showing a single-dim teach block render the tip; +// multi-dim callers (skip / no-progress) ignore it because one tip can't +// honestly summarize several principles at once. async function getStrongExample(args: { teamId: string; prompt: string; file_path?: string; target_dims: Dimension[]; team_context: string | null; -}): Promise { +}): Promise<{ example: string; tip: string }> { const wiki = args.file_path ? await fetchTopGraduatedForPath(args.teamId, args.file_path) : await fetchTopGraduatedForTeam(args.teamId); - if (wiki) return wiki; + if (wiki) return { example: wiki, tip: '' }; const fallback = await rewriteForDims({ prompt: args.prompt, @@ -321,7 +329,8 @@ async function getStrongExample(args: { file_path: args.file_path, team_context: args.team_context ?? undefined, }); - return fallback.rewritten_prompt; // empty string on Gemini failure + // Empty strings on Gemini failure — render block falls back accordingly. + return { example: fallback.rewritten_prompt, tip: fallback.tip }; } app.post('/coach', async (c) => { @@ -421,18 +430,33 @@ app.post('/coach', async (c) => { if (dimsToImprove.length === 0) { dimsToImprove = DIMENSIONS.filter((d) => originalDims[d] < 7); } - const strongRewrite = await getStrongExample({ - teamId, - prompt: body.original_prompt ?? body.prompt, - file_path: body.file_path, - target_dims: dimsToImprove.length ? dimsToImprove : ['specificity'], - team_context: teamContext, - }); - const text = strongRewrite + const originalPrompt = body.original_prompt ?? body.prompt; + // Run the rewrite and the closing summary in parallel — both are + // independent Gemini calls and the user is already waiting on the + // skip-reveal text. Each fails open to '' so a partial outage still + // produces a useful (if shorter) reveal. + const [strong, summary] = await Promise.all([ + getStrongExample({ + teamId, + prompt: originalPrompt, + file_path: body.file_path, + target_dims: dimsToImprove.length ? dimsToImprove : ['specificity'], + team_context: teamContext, + }), + summarizeCoaching({ + original_prompt: originalPrompt, + final_prompt: body.prompt, + original_dimensions: originalDims, + final_dimensions: scoreResult.dimensions, + reason: 'skip', + }), + ]); + const text = strong.example ? renderSkipReveal({ - strongRewrite, + strongRewrite: strong.example, originalDimensions: originalDims, reason: 'skip', + summary, }) : ''; const res: CoachResponse = { @@ -466,7 +490,7 @@ app.post('/coach', async (c) => { // Round 1, score <7 → first teach block. if (isRound1 && lowest) { - const strongExample = await getStrongExample({ + const strong = await getStrongExample({ teamId, prompt: body.prompt, file_path: body.file_path, @@ -476,7 +500,8 @@ app.post('/coach', async (c) => { const text = renderTeachBlock({ targetDim: lowest, targetScore: scoreResult.dimensions[lowest], - strongExample, + strongExample: strong.example, + tip: strong.tip, }); const next: CoachNextRoundInputs = { original_prompt: body.prompt, @@ -506,6 +531,13 @@ app.post('/coach', async (c) => { // Score crossed 7 → success reveal. if (overall >= 7) { + const summary = await summarizeCoaching({ + original_prompt: originalPrompt, + final_prompt: body.prompt, + original_dimensions: originalDims, + final_dimensions: scoreResult.dimensions, + reason: 'success', + }); const text = renderSuccessReveal({ originalPrompt, finalPrompt: body.prompt, @@ -513,6 +545,7 @@ app.post('/coach', async (c) => { finalOverall: overall, originalDimensions: originalDims, finalDimensions: scoreResult.dimensions, + summary, }); const res: CoachResponse = { proceed: true, @@ -541,19 +574,32 @@ app.post('/coach', async (c) => { if (dimsToImprove.length === 0) { dimsToImprove = DIMENSIONS.filter((d) => originalDims[d] < 7); } - const strongRewrite = await getStrongExample({ - teamId, - prompt: originalPrompt, - file_path: body.file_path, - target_dims: dimsToImprove.length ? dimsToImprove : [lowest!], - team_context: teamContext, - }); - const text = strongRewrite + // Parallel: rewrite + closing recap. Same fail-open posture as the + // skip-reveal branch — both helpers return '' on Gemini failure and the + // render fallback handles each independently. + const [strong, summary] = await Promise.all([ + getStrongExample({ + teamId, + prompt: originalPrompt, + file_path: body.file_path, + target_dims: dimsToImprove.length ? dimsToImprove : [lowest!], + team_context: teamContext, + }), + summarizeCoaching({ + original_prompt: originalPrompt, + final_prompt: body.prompt, + original_dimensions: originalDims, + final_dimensions: scoreResult.dimensions, + reason: 'no_progress', + }), + ]); + const text = strong.example ? renderSkipReveal({ - strongRewrite, + strongRewrite: strong.example, originalDimensions: originalDims, reason: 'no_progress', noProgressDim: previousLowest ?? undefined, + summary, }) : ''; const res: CoachResponse = { @@ -567,8 +613,15 @@ app.post('/coach', async (c) => { return c.json(res); } - // Round 3 forced exit (still <7, made progress, but rounds exhausted). + // Forced exit at COACH_MAX_ROUNDS (still <7, made progress, but rounds exhausted). if (round >= COACH_MAX_ROUNDS) { + const summary = await summarizeCoaching({ + original_prompt: originalPrompt, + final_prompt: body.prompt, + original_dimensions: originalDims, + final_dimensions: scoreResult.dimensions, + reason: 'max_rounds', + }); const text = renderSuccessReveal({ originalPrompt, finalPrompt: body.prompt, @@ -577,6 +630,7 @@ app.post('/coach', async (c) => { originalDimensions: originalDims, finalDimensions: scoreResult.dimensions, maxRoundsHit: true, + summary, }); const res: CoachResponse = { proceed: true, @@ -591,19 +645,32 @@ app.post('/coach', async (c) => { // Else: score still <7, made progress, more rounds remain. Keep teaching. if (lowest) { - const strongExample = await getStrongExample({ - teamId, - prompt: body.prompt, - file_path: body.file_path, - target_dims: [lowest], - team_context: teamContext, - }); + // Parallel: pull a fresh strong example AND ask Gemini to acknowledge + // what the user just added. Both feed renderTeachBlock; both fail-open + // to '' so the static template still produces a usable block. + const [strong, acknowledgment] = await Promise.all([ + getStrongExample({ + teamId, + prompt: body.prompt, + file_path: body.file_path, + target_dims: [lowest], + team_context: teamContext, + }), + acknowledgeProgress({ + previous_prompt: originalPrompt, + current_prompt: body.prompt, + previous_dimensions: previousDims, + current_dimensions: scoreResult.dimensions, + }), + ]); const text = renderTeachBlock({ targetDim: lowest, targetScore: scoreResult.dimensions[lowest], - strongExample, + strongExample: strong.example, previousLowestDim: previousLowest && previousLowest !== lowest ? previousLowest : undefined, + acknowledgment, + tip: strong.tip, }); const next: CoachNextRoundInputs = { original_prompt: originalPrompt, diff --git a/apps/api/src/team-context.ts b/apps/api/src/team-context.ts index 019539c..f941d5b 100644 Binary files a/apps/api/src/team-context.ts and b/apps/api/src/team-context.ts differ diff --git a/apps/browser-ext/README.md b/apps/browser-ext/README.md index d62ec5c..b8b1707 100644 --- a/apps/browser-ext/README.md +++ b/apps/browser-ext/README.md @@ -1,4 +1,4 @@ -# browser-ext — Trailhead extension for Claude.ai +# browser-ext — LearnLoop extension for Claude.ai The demo headline (spec §2). Live 5-dimension score-card under Claude.ai's textarea, four widgets injected into the conversation surface, Socratic-mode diff --git a/apps/browser-ext/manifest.json b/apps/browser-ext/manifest.json index 6a45040..f50e39d 100644 --- a/apps/browser-ext/manifest.json +++ b/apps/browser-ext/manifest.json @@ -1,6 +1,6 @@ { "manifest_version": 3, - "name": "Trailhead", + "name": "LearnLoop", "version": "0.0.1", "description": "Live prompt-quality coach for Claude.ai — 5-dimension score-card under your textarea.", "minimum_chrome_version": "120", @@ -10,7 +10,7 @@ "https://trailheadapi-production.up.railway.app/*" ], "action": { - "default_title": "Trailhead", + "default_title": "LearnLoop", "default_popup": "popup.html" }, "content_scripts": [ diff --git a/apps/browser-ext/package.json b/apps/browser-ext/package.json index b6ea8dc..784c30d 100644 --- a/apps/browser-ext/package.json +++ b/apps/browser-ext/package.json @@ -3,7 +3,7 @@ "version": "0.0.1", "private": true, "type": "module", - "description": "Trailhead browser extension — live 5-dimension score-card on Claude.ai", + "description": "LearnLoop browser extension — live 5-dimension score-card on Claude.ai", "scripts": { "build": "node esbuild.config.mjs", "watch": "node esbuild.config.mjs --watch", diff --git a/apps/browser-ext/src/api.ts b/apps/browser-ext/src/api.ts index c61f194..cd704c7 100644 --- a/apps/browser-ext/src/api.ts +++ b/apps/browser-ext/src/api.ts @@ -10,6 +10,8 @@ import type { CaptureRequest, CaptureResponse, + CoachRequest, + CoachResponse, DiffRequest, DiffResponse, ImproveRequest, @@ -22,7 +24,7 @@ import { API_URL, FETCH_TIMEOUT_MS, TRAILHEAD_ERROR_TAG } from './config.ts'; import { getTeamToken } from './team-state.ts'; import { getContextPath } from './context-state.ts'; -type EndpointKey = 'score' | 'capture' | 'diff' | 'wiki' | 'improve'; +type EndpointKey = 'score' | 'capture' | 'diff' | 'wiki' | 'improve' | 'coach'; const inflight = new Map(); function abortPrev(key: EndpointKey): AbortController { @@ -100,6 +102,35 @@ export async function wikiRecent(sinceIso: string): Promise('wiki', `/wiki/recent?${q}`, { method: 'GET' }); } +// /coach drives the multi-round educational score arc (curated DIMENSION_TEACH +// blocks, no-progress detection, success/skip reveals). The browser-ext's +// improve widget calls this in place of /improve so its coaching matches the +// MCP server's polished pattern. Same long-timeout policy as /improve since a +// single round runs a Gemini score + render. +export async function coach(body: CoachRequest): Promise { + const contextPath = body.context_path ?? getContextPath() ?? undefined; + const enriched: CoachRequest = contextPath ? { ...body, context_path: contextPath } : body; + const ac = new AbortController(); + const stop = setTimeout(() => ac.abort(), 25_000); + try { + const res = await fetch(`${API_URL}/coach`, { + method: 'POST', + headers: headers(), + body: JSON.stringify(enriched), + signal: ac.signal, + }); + if (!res.ok) return null; + return (await res.json()) as CoachResponse; + } catch (err) { + if (!(err instanceof DOMException && err.name === 'AbortError')) { + console.warn(`${TRAILHEAD_ERROR_TAG} /coach failed`, err); + } + return null; + } finally { + clearTimeout(stop); + } +} + // /improve calls take much longer than other endpoints (Gemini round-trip // per turn). Bypass the 4s default timeout — we manage our own through the // widget's UX (the user sees the … placeholder while it pends). diff --git a/apps/browser-ext/src/context-bundle.ts b/apps/browser-ext/src/context-bundle.ts index cc6a10b..8989a4e 100644 Binary files a/apps/browser-ext/src/context-bundle.ts and b/apps/browser-ext/src/context-bundle.ts differ diff --git a/apps/browser-ext/src/popup/popup.html b/apps/browser-ext/src/popup/popup.html index 175c36d..bc546ed 100644 --- a/apps/browser-ext/src/popup/popup.html +++ b/apps/browser-ext/src/popup/popup.html @@ -2,151 +2,340 @@ - Trailhead + LearnLoop -

Trailhead

- -
- -
-
- -
- -
- - -
- -
- - -
When off, the extension stops intercepting sends.
+
+
+ +
+
LearnLoop
+
Prompt coach
+
+
+
+ +
+ +
+
+
Coaching
+ + + + +
+
+
+ + +
+ +
+ + + + + +
+
+ +
When off, the extension stops intercepting sends.
+
+
diff --git a/apps/browser-ext/src/popup/popup.ts b/apps/browser-ext/src/popup/popup.ts index cd244e0..7e4256a 100644 --- a/apps/browser-ext/src/popup/popup.ts +++ b/apps/browser-ext/src/popup/popup.ts @@ -12,7 +12,7 @@ // change. import { API_URL } from '../config.ts'; -import { TEAM_TOKEN_KEY } from '../team-state.ts'; +import { TEAM_TOKEN_KEY, TEAM_NAME_KEY } from '../team-state.ts'; import { CONTEXT_PATH_KEY } from '../context-state.ts'; import { TEAM_TOKEN as DEFAULT_TEAM_TOKEN } from '../config.ts'; import type { @@ -49,8 +49,8 @@ function render(enabled: boolean): void { switchEl.classList.toggle('is-on', enabled); switchEl.setAttribute('aria-checked', String(enabled)); hintEl.textContent = enabled - ? 'When off, the extension stops intercepting sends.' - : 'Coaching is off — Claude.ai sends behave as if the extension weren’t installed.'; + ? 'Weak prompts open a quick coaching panel before they send.' + : 'Prompts send straight to Claude with no coaching.'; } async function getStoredToken(): Promise { @@ -80,6 +80,12 @@ async function setStoredToken(token: string): Promise { }); } +async function setStoredTeamName(name: string): Promise { + return new Promise((resolve) => { + (chrome as any).storage.local.set({ [TEAM_NAME_KEY]: name }, () => resolve()); + }); +} + async function getStoredContextPath(): Promise { return new Promise((resolve) => { (chrome as any).storage.local.get(CONTEXT_PATH_KEY, (v: Record) => { @@ -104,6 +110,9 @@ async function clearStoredContextPath(): Promise { async function refreshCurrentTeamName(): Promise { const token = await getStoredToken(); const team = cachedTeams?.find((t) => t.token === token); + // Backfill the cached display name whenever the popup discovers it via + // /teams — handles users who picked a team before this feature existed. + if (team) await setStoredTeamName(team.name); currentTeamNameEl.textContent = team ? team.name : token === DEFAULT_TEAM_TOKEN ? 'Acme (default)' : token.slice(0, 16) + '…'; @@ -111,7 +120,7 @@ async function refreshCurrentTeamName(): Promise { async function refreshCurrentContextName(): Promise { const path = await getStoredContextPath(); - currentContextNameEl.textContent = path ?? ''; + currentContextNameEl.textContent = displayPath(path); } function renderTeamList(teams: TeamSummary[], currentToken: string): void { @@ -130,6 +139,9 @@ function renderTeamList(teams: TeamSummary[], currentToken: string): void { li.appendChild(name); li.addEventListener('click', async () => { await setStoredToken(team.token); + // Persist the team's display name alongside the token so the + // in-page pill can show "Acme Fintech · Root" instead of just "Root". + await setStoredTeamName(team.name); // Picking a different team invalidates the wiki tree cache and // any active context (the path may not exist for the new team). if (cachedTreeForToken !== team.token) { @@ -210,6 +222,36 @@ function lastSegment(path: string): string { return i === -1 ? stripped : stripped.slice(i + 1); } +// Display name for a tree node. Root nodes (path '/' or '' or anything that +// reduces to an empty last segment) render as "Root" instead of a blank +// label — the previous behaviour left the row visually empty next to the +// folder icon. +function displayName(path: string): string { + const seg = lastSegment(path); + if (seg) return seg; + return 'Root'; +} + +// Translate a wiki-tree node path into the value we persist to chrome.storage. +// The root node has path = '' which clashes with the truthy-check used by +// getStoredContextPath / context-state.ts to mean "no context selected". +// We persist root as '/' so those callers see a non-empty string and treat +// it as an active context. context-bundle.ts and team-context.ts translate +// '/' back to '' when filtering the subtree, so the filter still matches +// every node under root. +const ROOT_SENTINEL = '/'; +function pathForStorage(path: string): string { + return path === '' ? ROOT_SENTINEL : path; +} +// Render either the bare path or the friendly "Root" label for the root +// sentinel — used in the popup's "Active: …" row, the toast, and the +// header label next to the Select-context button. +function displayPath(path: string | null): string { + if (!path) return ''; + if (path === ROOT_SENTINEL) return 'Root'; + return path; +} + function renderContextTree(nodes: WikiTreeNode[], currentPath: string | null): void { contextTreeEl.replaceChildren(); @@ -222,7 +264,7 @@ function renderContextTree(nodes: WikiTreeNode[], currentPath: string | null): v const lbl = document.createElement('span'); lbl.style.opacity = '0.7'; lbl.style.fontSize = '11px'; - lbl.textContent = `Active: ${currentPath}`; + lbl.textContent = `Active: ${displayPath(currentPath)}`; const clearBtn = document.createElement('button'); clearBtn.type = 'button'; clearBtn.className = 'clear-ctx-btn'; @@ -250,7 +292,9 @@ function renderContextTree(nodes: WikiTreeNode[], currentPath: string | null): v const depth = pathDepth(node.path); li.style.paddingLeft = `${6 + depth * 12}px`; li.title = node.path; - if (node.path === currentPath) { + // Compare on the STORED form so the root node ('') matches the + // sentinel value ('/') we persisted earlier. + if (pathForStorage(node.path) === currentPath) { li.classList.add('is-current'); const check = document.createElement('span'); check.className = 'check'; @@ -263,14 +307,15 @@ function renderContextTree(nodes: WikiTreeNode[], currentPath: string | null): v li.appendChild(icon); const label = document.createElement('span'); label.className = 'tree-label'; - label.textContent = lastSegment(node.path) || node.path; + label.textContent = displayName(node.path); li.appendChild(label); li.addEventListener('click', async () => { - await setStoredContextPath(node.path); - cachedTree && renderContextTree(cachedTree, node.path); + const stored = pathForStorage(node.path); + await setStoredContextPath(stored); + cachedTree && renderContextTree(cachedTree, stored); await refreshCurrentContextName(); closeContextDropdown(); - showToast(`Context set: ${node.path}`); + showToast(`Context set: ${displayName(node.path)}`); }); contextTreeEl.appendChild(li); } diff --git a/apps/browser-ext/src/styles.ts b/apps/browser-ext/src/styles.ts index 1eb9074..3cd94a8 100644 --- a/apps/browser-ext/src/styles.ts +++ b/apps/browser-ext/src/styles.ts @@ -8,472 +8,877 @@ export const TRAILHEAD_STYLESHEET_ID = 'trailhead-injected-styles'; export const TRAILHEAD_CSS = ` +/* ============================================================ + * Design tokens — id-scoped so they don't leak into the host page. + * Re-declared on every Trailhead surface that needs them. + * ============================================================ */ +#trailhead-score-card, +#trailhead-context-pill, +#trailhead-toast-stack { + --th-accent: #6c8cff; + --th-accent-strong: #4a6bff; + --th-accent-bg: rgba(108,140,255,0.14); + --th-accent-border: rgba(108,140,255,0.42); + --th-good: #74d18c; + --th-good-bg: rgba(116,209,140,0.16); + --th-warn: #ffce6e; + --th-warn-bg: rgba(255,206,110,0.14); + --th-bad: #ff8773; + --th-bad-bg: rgba(255,135,115,0.14); + --th-surface-1: rgba(255,255,255,0.04); + --th-surface-2: rgba(255,255,255,0.07); + --th-surface-hover: rgba(255,255,255,0.10); + --th-border: rgba(255,255,255,0.10); + --th-border-strong: rgba(255,255,255,0.16); + --th-text-faint: rgba(255,255,255,0.45); + --th-text-muted: rgba(255,255,255,0.65); + --th-radius-lg: 12px; + --th-radius: 10px; + --th-radius-sm: 6px; + --th-radius-pill: 999px; + --th-shadow-md: 0 6px 22px rgba(0,0,0,0.32); + --th-ease: cubic-bezier(0.4, 0.0, 0.2, 1); +} + +/* ============================================================ + * #trailhead-score-card — main inline panel below the composer. + * ============================================================ */ #trailhead-score-card { - margin: 8px auto 0; - padding: 10px 12px; - border-radius: 8px; - background: rgba(255, 255, 255, 0.04); - border: 1px solid rgba(255, 255, 255, 0.10); + margin: 10px auto 0; + padding: 16px 18px; + border-radius: var(--th-radius-lg); + background: linear-gradient(180deg, rgba(255,255,255,0.05), rgba(255,255,255,0.025)); + border: 1px solid var(--th-border); font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; font-size: 13px; - color: var(--text-primary, #ddd); + color: var(--text-primary, #e5e7ee); max-width: 760px; box-sizing: border-box; - transition: border-color 200ms ease-out, box-shadow 200ms ease-out; + box-shadow: var(--th-shadow-md); + transition: border-color 200ms var(--th-ease), box-shadow 200ms var(--th-ease), transform 200ms var(--th-ease); + animation: trailhead-card-in 220ms var(--th-ease); +} +#trailhead-score-card[data-bucket="low"] { + border-color: rgba(208,74,58,0.55); + box-shadow: var(--th-shadow-md), inset 3px 0 0 0 #d04a3a; +} +#trailhead-score-card[data-bucket="med"] { + border-color: rgba(214,168,60,0.55); + box-shadow: var(--th-shadow-md), inset 3px 0 0 0 #d6a83c; +} +#trailhead-score-card[data-bucket="high"] { + border-color: rgba(255,255,255,0.06); + box-shadow: 0 2px 10px rgba(0,0,0,0.18); } -#trailhead-score-card[data-bucket="low"] { border-color: #d04a3a; } -#trailhead-score-card[data-bucket="med"] { border-color: #d6a83c; } -#trailhead-score-card[data-bucket="high"] { border-color: rgba(255,255,255,0.06); } #trailhead-score-card[hidden] { display: none; } +/* Force hidden descendants to truly hide. Without this, author display + * rules below (e.g. .trailhead-improve-thread sets display: flex) override + * the UA hidden rule because they share specificity, and different + * improve-chat stages would bleed into each other. */ +#trailhead-score-card [hidden] { display: none !important; } +@keyframes trailhead-card-in { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} +/* Loading state */ .trailhead-loading { + display: flex; + align-items: center; + gap: 10px; font-size: 13px; - opacity: 0.7; - padding: 6px 0; + opacity: 0.85; + padding: 4px 0; } .trailhead-loading::after { content: ''; display: inline-block; - margin-left: 6px; - width: 8px; height: 8px; - border: 1.5px solid rgba(255,255,255,0.4); - border-top-color: #4a6bff; + width: 14px; + height: 14px; + border: 2px solid rgba(255,255,255,0.18); + border-top-color: var(--th-accent); border-radius: 50%; animation: trailhead-spin 700ms linear infinite; - vertical-align: -1px; } @keyframes trailhead-spin { to { transform: rotate(360deg); } } +/* Overall score header — "Score 7/10" */ .trailhead-sc-overall { - font-size: 16px; + font-size: 15px; font-weight: 600; - margin-bottom: 6px; + margin-bottom: 10px; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 4px 12px 4px 8px; + border-radius: var(--th-radius-pill); + background: var(--th-surface-1); + border: 1px solid var(--th-border); + letter-spacing: 0.01em; } -.trailhead-sc-overall.is-low { color: #ff8773; } -.trailhead-sc-overall.is-med { color: #ffce6e; } -.trailhead-sc-overall.is-high { color: #74d18c; } +.trailhead-sc-overall::before { + content: ''; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--th-text-faint); + flex: 0 0 auto; +} +.trailhead-sc-overall.is-low { color: #ff8773; border-color: rgba(208,74,58,0.40); background: var(--th-bad-bg); } +.trailhead-sc-overall.is-low::before { background: #ff8773; box-shadow: 0 0 0 3px var(--th-bad-bg); } +.trailhead-sc-overall.is-med { color: #ffce6e; border-color: rgba(214,168,60,0.40); background: var(--th-warn-bg); } +.trailhead-sc-overall.is-med::before { background: #ffce6e; box-shadow: 0 0 0 3px var(--th-warn-bg); } +.trailhead-sc-overall.is-high { color: #74d18c; border-color: rgba(116,209,140,0.40); background: var(--th-good-bg); } +.trailhead-sc-overall.is-high::before { background: #74d18c; box-shadow: 0 0 0 3px var(--th-good-bg); } -.trailhead-sc-rows { display: grid; gap: 2px; } +/* Dimension rows */ +.trailhead-sc-rows { + display: grid; + gap: 6px; + margin-bottom: 4px; +} .trailhead-sc-row { display: grid; - grid-template-columns: 16px 1fr 32px; - gap: 8px; + grid-template-columns: 22px 1fr auto; + gap: 12px; align-items: center; - padding: 2px 0; -} -.trailhead-sc-icon { font-weight: 700; text-align: center; } -.trailhead-sc-icon.is-ok { color: #74d18c; } -.trailhead-sc-icon.is-bad { color: #ff8773; } -.trailhead-sc-name { opacity: 0.92; } -.trailhead-sc-score { text-align: right; opacity: 0.85; font-variant-numeric: tabular-nums; } -.trailhead-sc-hint { - grid-column: 2 / span 2; + padding: 8px 12px; + border-radius: var(--th-radius-sm); + background: var(--th-surface-1); + border: 1px solid transparent; + transition: background 120ms var(--th-ease), border-color 120ms var(--th-ease); +} +.trailhead-sc-row:hover { background: var(--th-surface-2); } +.trailhead-sc-row.is-low { border-color: rgba(208,74,58,0.22); } +.trailhead-sc-row.is-med { border-color: rgba(214,168,60,0.18); } + +.trailhead-sc-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 50%; + font-weight: 700; + font-size: 12px; + line-height: 1; +} +.trailhead-sc-icon.is-ok { + color: #74d18c; + background: var(--th-good-bg); +} +.trailhead-sc-icon.is-bad { + color: #ff8773; + background: var(--th-bad-bg); +} + +.trailhead-sc-name { + opacity: 0.94; + text-transform: capitalize; + font-weight: 500; +} + +.trailhead-sc-score { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 36px; + height: 22px; + padding: 0 10px; + font-variant-numeric: tabular-nums; + font-weight: 600; + font-size: 12.5px; + line-height: 1; + border-radius: var(--th-radius-pill); + background: var(--th-surface-2); + color: var(--th-text-muted); + box-sizing: border-box; +} +.trailhead-sc-row.is-low .trailhead-sc-score { color: #ff8773; background: var(--th-bad-bg); } +.trailhead-sc-row.is-med .trailhead-sc-score { color: #ffce6e; background: var(--th-warn-bg); } +.trailhead-sc-row.is-high .trailhead-sc-score { color: #74d18c; background: var(--th-good-bg); } + +.trailhead-sc-hint { + grid-column: 1 / -1; font-size: 11.5px; - opacity: 0.75; - margin: -2px 0 4px 0; + opacity: 0.80; + margin: 4px 0 8px 34px; + padding-left: 10px; + border-left: 2px solid var(--th-border-strong); + line-height: 1.45; } +/* Action buttons — Improve / Keep as-is / Edit */ .trailhead-actions { display: flex; - gap: 8px; - margin-top: 10px; + gap: 10px; + margin-top: 16px; + padding-top: 14px; + border-top: 1px solid var(--th-border); } .trailhead-actions button { cursor: pointer; font: inherit; - padding: 4px 10px; - border-radius: 6px; - border: 1px solid rgba(255,255,255,0.18); - background: rgba(255,255,255,0.04); + font-size: 12.5px; + font-weight: 500; + padding: 7px 14px; + border-radius: var(--th-radius-sm); + border: 1px solid var(--th-border-strong); + background: var(--th-surface-1); color: inherit; + transition: background 150ms var(--th-ease), border-color 150ms var(--th-ease), transform 80ms var(--th-ease); +} +.trailhead-actions button:hover { + background: var(--th-surface-hover); + border-color: rgba(255,255,255,0.22); +} +.trailhead-actions button:active { transform: translateY(1px); } +.trailhead-actions button:focus-visible { + outline: none; + border-color: var(--th-accent); + box-shadow: 0 0 0 3px var(--th-accent-bg); } -.trailhead-actions button:hover { background: rgba(255,255,255,0.10); } .trailhead-actions button.is-primary { - background: #4a6bff; - border-color: #4a6bff; + background: linear-gradient(135deg, #6c8cff, #4a6bff); + border-color: transparent; color: #fff; + box-shadow: 0 2px 8px rgba(74,107,255,0.30); +} +.trailhead-actions button.is-primary:hover { + background: linear-gradient(135deg, #7c98ff, #5b78ff); + box-shadow: 0 3px 12px rgba(74,107,255,0.40); } -.trailhead-actions button.is-primary:hover { background: #3a5be0; } -/* Widget A — score badge on user bubbles */ +/* ============================================================ + * Widget A — score badge on user bubbles. + * ============================================================ */ .trailhead-badge { display: inline-block; margin: 4px 6px 0 0; - padding: 1px 8px; - border-radius: 999px; + padding: 2px 9px; + border-radius: var(--th-radius-pill); font-size: 11px; + font-weight: 600; border: 1px solid rgba(255,255,255,0.16); - background: rgba(0,0,0,0.20); + background: rgba(0,0,0,0.25); cursor: pointer; user-select: none; vertical-align: middle; + transition: transform 120ms cubic-bezier(0.4,0,0.2,1), box-shadow 120ms cubic-bezier(0.4,0,0.2,1); } -.trailhead-badge.is-low { color: #ff8773; border-color: #6e2a23; } -.trailhead-badge.is-med { color: #ffce6e; border-color: #6a541d; } -.trailhead-badge.is-high { color: #74d18c; border-color: #2c5839; } +.trailhead-badge:hover { transform: translateY(-1px); } +.trailhead-badge.is-low { color: #ff8773; border-color: rgba(208,74,58,0.45); background: rgba(208,74,58,0.10); box-shadow: 0 1px 6px rgba(208,74,58,0.20); } +.trailhead-badge.is-med { color: #ffce6e; border-color: rgba(214,168,60,0.45); background: rgba(214,168,60,0.10); box-shadow: 0 1px 6px rgba(214,168,60,0.20); } +.trailhead-badge.is-high { color: #74d18c; border-color: rgba(116,209,140,0.45); background: rgba(116,209,140,0.10); box-shadow: 0 1px 6px rgba(116,209,140,0.20); } .trailhead-badge-tooltip { - margin-top: 4px; - padding: 8px; - border-radius: 6px; - background: rgba(0,0,0,0.35); - border: 1px solid rgba(255,255,255,0.10); + margin-top: 6px; + padding: 10px 12px; + border-radius: 8px; + background: rgba(0,0,0,0.45); + border: 1px solid rgba(255,255,255,0.12); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + font-size: 12px; + line-height: 1.45; } -/* Widget B — outcome rating chips */ +/* ============================================================ + * Widget B — outcome rating chips. + * ============================================================ */ .trailhead-outcome { display: flex; gap: 6px; - margin-top: 6px; + margin-top: 8px; font-size: 12px; } .trailhead-outcome-chip { cursor: pointer; - padding: 2px 8px; + padding: 4px 12px; border-radius: 999px; - border: 1px solid rgba(255,255,255,0.16); - background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.18); + background: rgba(255,255,255,0.05); user-select: none; + font-weight: 500; + transition: background 120ms ease, border-color 120ms ease, transform 80ms ease; +} +.trailhead-outcome-chip:hover { + background: rgba(255,255,255,0.12); + border-color: rgba(255,255,255,0.28); } -.trailhead-outcome-chip:hover { background: rgba(255,255,255,0.10); } +.trailhead-outcome-chip:active { transform: translateY(1px); } .trailhead-outcome-recorded { - display: inline-block; + display: inline-flex; + align-items: center; + gap: 6px; margin-top: 6px; - padding: 2px 8px; + padding: 4px 12px; border-radius: 999px; - background: #2c5839; + background: rgba(116,209,140,0.18); + border: 1px solid rgba(116,209,140,0.40); color: #d8f1de; font-size: 12px; + font-weight: 500; +} +.trailhead-outcome-recorded::before { + content: '✓'; + font-weight: 700; + color: #74d18c; } -/* Widget C — prompt-diff link & inline panel */ +/* ============================================================ + * Widget C — prompt-diff link & inline panel. + * ============================================================ */ .trailhead-diff-link { cursor: pointer; font-size: 11px; margin-left: 6px; opacity: 0.7; text-decoration: underline; + text-underline-offset: 2px; user-select: none; display: inline-block; + transition: opacity 120ms ease, color 120ms ease; } -.trailhead-diff-link:hover { opacity: 1; } +.trailhead-diff-link:hover { opacity: 1; color: #6c8cff; } .trailhead-diff-panel { - margin-top: 8px; - padding: 10px; - border-radius: 8px; - border: 1px solid rgba(255,255,255,0.10); - background: rgba(255,255,255,0.04); + margin-top: 10px; + padding: 12px 14px; + border-radius: 10px; + border: 1px solid rgba(255,255,255,0.12); + background: linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.02)); font-size: 12px; + animation: trailhead-card-in 200ms cubic-bezier(0.4,0,0.2,1); } .trailhead-diff-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; - margin-top: 6px; + margin-top: 8px; } .trailhead-diff-col h4 { - margin: 0 0 4px 0; - font-size: 11px; - opacity: 0.7; + margin: 0 0 6px 0; + font-size: 10.5px; + opacity: 0.6; text-transform: uppercase; - letter-spacing: 0.04em; + letter-spacing: 0.08em; + font-weight: 600; } .trailhead-diff-prompt { white-space: pre-wrap; word-break: break-word; - background: rgba(0,0,0,0.18); - padding: 6px; - border-radius: 4px; - font-family: ui-monospace, monospace; + background: rgba(0,0,0,0.25); + padding: 8px 10px; + border-radius: 6px; + border: 1px solid rgba(255,255,255,0.06); + font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 11.5px; + line-height: 1.5; } -.trailhead-diff-narrative { margin-top: 8px; opacity: 0.85; } +.trailhead-diff-narrative { margin-top: 10px; opacity: 0.85; line-height: 1.45; } .trailhead-diff-error { margin-top: 6px; font-size: 12px; opacity: 0.7; } -/* Widget D — wiki toasts. Mounted in a fixed-position container on - * document.body (Option β, spec §9.3) so Claude.ai's React reconciliation - * inside the conversation tree cannot tear them down. */ +/* ============================================================ + * Widget D — wiki toasts (fixed-position stack on document.body). + * Mounted in a fixed-position container (Option β, spec §9.3) so + * Claude.ai's React reconciliation cannot tear them down. + * ============================================================ */ #trailhead-toast-stack { position: fixed; - top: 16px; + /* Sits below the context pill (top: 16px). On narrow Claude layouts the + * pill drifts close to the viewport right edge; 64px keeps the toast + * stack clear of it. On wide layouts the pill is centered with the chat + * column, far from the right edge — the 48px extra inset is harmless. */ + top: 64px; right: 16px; z-index: 2147483646; /* below browser chrome, above everything else */ display: flex; flex-direction: column; - gap: 8px; + gap: 10px; pointer-events: none; /* clicks fall through gaps; toasts re-enable */ - max-width: 360px; + max-width: 380px; width: max-content; } .trailhead-toast { pointer-events: auto; - padding: 10px 12px; - border-radius: 8px; - background: rgba(20, 22, 32, 0.92); - backdrop-filter: blur(6px); - border: 1px solid rgba(74,107,255,0.45); - box-shadow: 0 6px 20px rgba(0,0,0,0.35); + padding: 12px 14px; + border-radius: 10px; + background: linear-gradient(180deg, rgba(28,32,46,0.95), rgba(20,22,32,0.95)); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid rgba(108,140,255,0.42); + box-shadow: 0 8px 24px rgba(0,0,0,0.42); font-size: 12.5px; font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + line-height: 1.45; color: #cfd8ff; cursor: pointer; user-select: none; - animation: trailhead-toast-in 240ms ease-out; + animation: trailhead-toast-in 240ms cubic-bezier(0.4,0,0.2,1); + transition: transform 120ms cubic-bezier(0.4,0,0.2,1), box-shadow 120ms cubic-bezier(0.4,0,0.2,1); +} +.trailhead-toast:hover { + transform: translateX(-2px); + box-shadow: 0 10px 28px rgba(0,0,0,0.50); } .trailhead-toast.is-promoted { - border-color: rgba(74,209,140,0.55); + border-color: rgba(116,209,140,0.55); color: #d8f1de; + background: linear-gradient(180deg, rgba(28,46,34,0.95), rgba(20,32,24,0.95)); } .trailhead-toast.is-leaving { animation: trailhead-toast-out 200ms ease-in forwards; } @keyframes trailhead-toast-in { - from { opacity: 0; transform: translateX(8px); } + from { opacity: 0; transform: translateX(12px); } to { opacity: 1; transform: translateX(0); } } @keyframes trailhead-toast-out { from { opacity: 1; transform: translateX(0); } - to { opacity: 0; transform: translateX(8px); } + to { opacity: 0; transform: translateX(12px); } } -/* Improve chat widget — replaces the score-card body when the user - * clicks Improve. Spec: 2026-04-26-improve-widget-design.md */ +/* ============================================================ + * Improve-chat widget — replaces the score-card body when the + * user clicks Improve. Spec: 2026-04-26-improve-widget-design.md + * ============================================================ */ #trailhead-score-card[data-mode="improve"] { - border-color: rgba(177, 185, 249, 0.4); + border-color: rgba(108,140,255,0.45); + box-shadow: var(--th-shadow-md), inset 3px 0 0 0 var(--th-accent); } .trailhead-improve-header { display: flex; align-items: center; - gap: 8px; - margin-bottom: 8px; + gap: 12px; + margin-bottom: 14px; + padding-bottom: 12px; + border-bottom: 1px solid var(--th-border); +} +.trailhead-improve-header::before { + content: ''; + display: inline-flex; + width: 22px; + height: 22px; + border-radius: 6px; + background: linear-gradient(135deg, #6c8cff, #4a6bff); + flex: 0 0 auto; + box-shadow: 0 2px 8px rgba(74,107,255,0.30), inset 0 1px 0 rgba(255,255,255,0.18); } .trailhead-improve-header-title { flex: 1; font-weight: 600; - font-size: 13px; - color: rgba(255,255,255,0.85); + font-size: 13.5px; + color: rgba(255,255,255,0.92); + letter-spacing: 0.01em; } .trailhead-improve-close { - background: rgba(255,255,255,0.06); - border: 1px solid rgba(255,255,255,0.10); + background: var(--th-surface-2); + border: 1px solid var(--th-border); color: rgba(255,255,255,0.85); font: inherit; - width: 24px; - height: 24px; + width: 26px; + height: 26px; line-height: 1; font-size: 18px; - border-radius: 6px; + border-radius: var(--th-radius-sm); cursor: pointer; padding: 0; display: inline-flex; align-items: center; justify-content: center; + transition: background 120ms var(--th-ease), color 120ms var(--th-ease); +} +.trailhead-improve-close:hover { + background: rgba(255,135,115,0.18); + color: #ff8773; + border-color: rgba(255,135,115,0.40); } -.trailhead-improve-close:hover { background: rgba(255,255,255,0.14); } + .trailhead-improve-thread { display: flex; flex-direction: column; - gap: 6px; - max-height: 260px; + gap: 10px; + max-height: 320px; overflow-y: auto; - padding: 8px; - background: rgba(0,0,0,0.18); - border-radius: 6px; - margin-bottom: 8px; -} + padding: 14px; + background: rgba(0,0,0,0.22); + border: 1px solid var(--th-border); + border-radius: var(--th-radius); + margin-bottom: 12px; +} +.trailhead-improve-thread::-webkit-scrollbar { width: 6px; } +.trailhead-improve-thread::-webkit-scrollbar-thumb { + background: rgba(255,255,255,0.12); + border-radius: 3px; +} +.trailhead-improve-thread::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.20); } + .trailhead-bubble { - padding: 6px 10px; - border-radius: 8px; + padding: 8px 12px; + border-radius: 10px; font-size: 12.5px; - line-height: 1.4; + line-height: 1.45; white-space: pre-wrap; max-width: 86%; + word-wrap: break-word; + animation: trailhead-bubble-in 180ms var(--th-ease); +} +@keyframes trailhead-bubble-in { + from { opacity: 0; transform: translateY(2px); } + to { opacity: 1; transform: translateY(0); } } .trailhead-bubble--assistant { - background: rgba(177,185,249,0.10); + background: rgba(108,140,255,0.12); + border: 1px solid rgba(108,140,255,0.22); align-self: flex-start; + border-top-left-radius: 4px; } .trailhead-bubble--user { - background: rgba(74,107,255,0.18); + background: linear-gradient(135deg, rgba(108,140,255,0.30), rgba(74,107,255,0.22)); + border: 1px solid rgba(108,140,255,0.30); align-self: flex-end; + border-top-right-radius: 4px; } -.trailhead-bubble--pending { opacity: 0.55; font-style: italic; } +.trailhead-bubble--pending { + opacity: 0.65; + font-style: italic; +} + .trailhead-improve-input-row { display: grid; - grid-template-columns: 1fr auto auto auto; - gap: 6px; + grid-template-columns: 1fr auto; + gap: 8px; align-items: stretch; + margin-bottom: 4px; } .trailhead-improve-input { resize: vertical; - min-height: 36px; - background: rgba(0,0,0,0.25); - border: 1px solid rgba(255,255,255,0.10); - border-radius: 6px; + min-height: 40px; + background: rgba(0,0,0,0.28); + border: 1px solid var(--th-border); + border-radius: var(--th-radius-sm); color: inherit; font: inherit; - padding: 6px 8px; + padding: 8px 10px; box-sizing: border-box; + transition: border-color 150ms var(--th-ease), background 150ms var(--th-ease); +} +.trailhead-improve-input:focus { + outline: none; + border-color: var(--th-accent); + background: rgba(0,0,0,0.32); + box-shadow: 0 0 0 3px var(--th-accent-bg); } .trailhead-improve-input:disabled { opacity: 0.5; } + .trailhead-improve-input-row button, .trailhead-improve-preview-actions button, .trailhead-improve-error-actions button { - background: rgba(255,255,255,0.06); - border: 1px solid rgba(255,255,255,0.10); - border-radius: 6px; + background: var(--th-surface-2); + border: 1px solid var(--th-border-strong); + border-radius: var(--th-radius-sm); color: inherit; font: inherit; - padding: 6px 12px; + font-size: 12.5px; + font-weight: 500; + padding: 8px 16px; + min-height: 34px; cursor: pointer; + transition: background 150ms var(--th-ease), border-color 150ms var(--th-ease), transform 80ms var(--th-ease); } .trailhead-improve-input-row button:hover:not(:disabled), .trailhead-improve-preview-actions button:hover:not(:disabled), .trailhead-improve-error-actions button:hover:not(:disabled) { - background: rgba(255,255,255,0.10); + background: var(--th-surface-hover); + border-color: rgba(255,255,255,0.22); +} +.trailhead-improve-input-row button:active:not(:disabled), +.trailhead-improve-preview-actions button:active:not(:disabled), +.trailhead-improve-error-actions button:active:not(:disabled) { + transform: translateY(1px); } .trailhead-improve-input-row button.is-primary, .trailhead-improve-preview-actions button.is-primary, .trailhead-improve-error-actions button.is-primary { - background: #4a6bff; - border-color: #4a6bff; + background: linear-gradient(135deg, #6c8cff, #4a6bff); + border-color: transparent; color: #fff; + box-shadow: 0 2px 8px rgba(74,107,255,0.28); } .trailhead-improve-input-row button.is-primary:hover:not(:disabled), .trailhead-improve-preview-actions button.is-primary:hover:not(:disabled), .trailhead-improve-error-actions button.is-primary:hover:not(:disabled) { - background: #3a5be0; + background: linear-gradient(135deg, #7c98ff, #5b78ff); + box-shadow: 0 3px 12px rgba(74,107,255,0.40); } .trailhead-improve-input-row button:disabled, .trailhead-improve-preview-actions button:disabled, .trailhead-improve-error-actions button:disabled { opacity: 0.45; cursor: not-allowed; + box-shadow: none; } + .trailhead-improve-preview, .trailhead-improve-error { display: flex; flex-direction: column; - gap: 8px; + gap: 10px; +} +/* "What changed" lesson recap shown above the polished prompt in preview. */ +.trailhead-improve-preview-rationale { + display: flex; + flex-direction: column; + gap: 4px; + padding: 10px 14px; + margin-bottom: 8px; + background: linear-gradient(135deg, rgba(108,140,255,0.10), rgba(108,140,255,0.04)); + border: 1px solid rgba(108,140,255,0.32); + border-radius: var(--th-radius); + border-left: 3px solid var(--th-accent); +} +.trailhead-improve-preview-rationale-label { + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--th-accent); +} +.trailhead-improve-preview-rationale-label::before { + content: '💡 '; + margin-right: 2px; +} +.trailhead-improve-preview-rationale-text { + font-size: 12.5px; + line-height: 1.5; + color: rgba(255,255,255,0.88); } + .trailhead-improve-preview-body { - background: rgba(0,0,0,0.25); - padding: 10px; - border-radius: 6px; + background: rgba(0,0,0,0.28); + padding: 14px 16px; + border-radius: var(--th-radius); + border: 1px solid var(--th-border); white-space: pre-wrap; word-break: break-word; - font: 12.5px/1.45 ui-monospace, "SF Mono", Menlo, monospace; - max-height: 260px; + font: 12.5px/1.55 ui-monospace, "SF Mono", Menlo, monospace; + max-height: 280px; overflow-y: auto; - margin: 0; + margin: 0 0 10px 0; + color: rgba(255,255,255,0.92); +} +.trailhead-improve-preview-body::-webkit-scrollbar { width: 6px; } +.trailhead-improve-preview-body::-webkit-scrollbar-thumb { + background: rgba(255,255,255,0.12); + border-radius: 3px; +} +/* Wrapper for the two preview action rows. Right-aligns the buttons so + * a single visible row (asking stage: just disabled "Use AI prompt") sits + * to the right, and two visible rows (preview stage: "I'm done" + enabled + * "Use AI prompt") sit on the same horizontal line. */ +.trailhead-improve-actions-wrap { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 8px; + flex-wrap: wrap; + margin-top: 4px; +} +.trailhead-improve-preview-actions { + display: flex; + gap: 8px; } -.trailhead-improve-preview-actions, .trailhead-improve-error-actions { display: flex; - gap: 6px; + gap: 8px; + justify-content: flex-end; } .trailhead-improve-error-msg { - color: rgba(255,140,140,0.92); + color: #ff8c8c; font-size: 12.5px; + padding: 10px 12px; + background: var(--th-bad-bg); + border: 1px solid rgba(208,74,58,0.40); + border-radius: var(--th-radius-sm); } /* Improve-chat choice screen (initial state) */ .trailhead-improve-choice { display: flex; flex-direction: column; - gap: 10px; + gap: 16px; + padding: 8px 4px 4px; + text-align: center; } .trailhead-improve-choice-intro { - font-size: 12.5px; - opacity: 0.85; + font-size: 13.5px; + opacity: 0.92; + line-height: 1.55; + max-width: 480px; + margin: 0 auto; } .trailhead-improve-choice-actions { display: flex; - gap: 6px; + gap: 10px; flex-wrap: wrap; + justify-content: center; + margin-top: 4px; +} +.trailhead-improve-choice-actions button.is-primary { + padding: 10px 24px; + font-size: 13px; + letter-spacing: 0.01em; } .trailhead-improve-choice-actions button { - background: rgba(255,255,255,0.06); - border: 1px solid rgba(255,255,255,0.10); - border-radius: 6px; + background: var(--th-surface-2); + border: 1px solid var(--th-border-strong); + border-radius: var(--th-radius-sm); color: inherit; font: inherit; - padding: 6px 12px; + font-size: 12.5px; + font-weight: 500; + padding: 8px 14px; cursor: pointer; display: inline-flex; align-items: center; - gap: 6px; + gap: 8px; + transition: background 150ms var(--th-ease), border-color 150ms var(--th-ease), transform 80ms var(--th-ease); +} +.trailhead-improve-choice-actions button:hover { + background: var(--th-surface-hover); + border-color: rgba(255,255,255,0.22); } -.trailhead-improve-choice-actions button:hover { background: rgba(255,255,255,0.10); } +.trailhead-improve-choice-actions button:active { transform: translateY(1px); } .trailhead-improve-choice-actions button.is-primary { - background: #4a6bff; - border-color: #4a6bff; + background: linear-gradient(135deg, #6c8cff, #4a6bff); + border-color: transparent; color: #fff; + box-shadow: 0 2px 8px rgba(74,107,255,0.28); +} +.trailhead-improve-choice-actions button.is-primary:hover { + background: linear-gradient(135deg, #7c98ff, #5b78ff); + box-shadow: 0 3px 12px rgba(74,107,255,0.40); } -.trailhead-improve-choice-actions button.is-primary:hover { background: #3a5be0; } .trailhead-improve-soon { - font-size: 10px; - opacity: 0.7; - padding: 1px 6px; + font-size: 9.5px; + opacity: 0.85; + padding: 2px 7px; border-radius: 999px; - background: rgba(255,255,255,0.10); + background: rgba(255,255,255,0.12); text-transform: uppercase; - letter-spacing: 0.05em; + letter-spacing: 0.06em; + font-weight: 600; } .trailhead-improve-choice-note { font-size: 11.5px; - opacity: 0.75; - padding: 6px 8px; - background: rgba(0,0,0,0.18); - border-radius: 4px; + opacity: 0.78; + padding: 8px 10px; + background: rgba(0,0,0,0.20); + border: 1px solid var(--th-border); + border-radius: var(--th-radius-sm); + line-height: 1.45; } +/* ============================================================ + * #trailhead-context-pill — sticky reminder of the active wiki + * context. Mounted on document.body with position: fixed; top: 16px. + * The right value is set INLINE by context-pill.ts on mount and via + * a ResizeObserver, computed from the composer's bounding rect so + * the pill always lines up with the right edge of the visible chat + * column (independent of sidebar state, window width, or Claude.ai + * layout variant). + * ============================================================ */ #trailhead-context-pill { + position: fixed; + /* Anchored to the right edge of the viewport, 10% inset (so the pill + * floats over the right side of the chat without hugging the corner). */ + right: 4%; + top: 15px; + z-index: 2147483645; /* one below the toast stack */ display: flex; align-items: center; - gap: 6px; - width: fit-content; - max-width: 320px; - margin: 6px 0 0 auto; - padding: 5px 8px 5px 10px; - border-radius: 999px; - background: rgba(74,107,255,0.14); - border: 1px solid rgba(74,107,255,0.42); - color: var(--text-primary, #ddd); + gap: 8px; + max-width: 360px; + padding: 6px 8px 6px 12px; + border-radius: var(--th-radius-pill); + background: var(--th-accent-bg); + border: 1px solid var(--th-accent-border); + color: var(--text-primary, #e5e7ee); font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; font-size: 12px; + box-shadow: 0 4px 14px rgba(74,107,255,0.22), 0 1px 3px rgba(0,0,0,0.20); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + transition: background 150ms var(--th-ease), border-color 150ms var(--th-ease); +} +#trailhead-context-pill:hover { + background: rgba(108,140,255,0.20); + border-color: rgba(108,140,255,0.55); } #trailhead-context-pill[hidden] { display: none; } -.trailhead-context-pill-icon { font-size: 12px; } -.trailhead-context-pill-prefix { opacity: 0.7; } +/* Bump the right inset on narrower viewports — at 4% the pill starts to + * crowd the chat column when the window shrinks (or the sidebar opens). */ +@media (max-width: 1280px) { + #trailhead-context-pill { right: 6%; } +} +.trailhead-context-pill-icon { font-size: 12px; opacity: 0.85; } +.trailhead-context-pill-prefix { + opacity: 0.72; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; + font-weight: 600; +} .trailhead-context-pill-label { font-weight: 600; font-family: ui-monospace, "SF Mono", Menlo, monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - max-width: 320px; + max-width: 240px; } .trailhead-context-pill-clear { - margin-left: 4px; - background: transparent; - border: none; + margin-left: 2px; + background: rgba(0,0,0,0.20); + border: 1px solid transparent; color: inherit; font: inherit; - opacity: 0.6; + font-size: 11px; + width: 20px; + height: 20px; + line-height: 1; + border-radius: 50%; + opacity: 0.75; cursor: pointer; - padding: 0 4px; - border-radius: 4px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + transition: background 120ms var(--th-ease), opacity 120ms var(--th-ease); } .trailhead-context-pill-clear:hover { - background: rgba(255,255,255,0.10); + background: rgba(255,135,115,0.22); + border-color: rgba(255,135,115,0.40); opacity: 1; + color: #ff8773; +} + +/* Reduced motion */ +@media (prefers-reduced-motion: reduce) { + #trailhead-score-card, + #trailhead-context-pill, + #trailhead-toast-stack *, + .trailhead-bubble, + .trailhead-diff-panel { + animation-duration: 1ms !important; + transition-duration: 1ms !important; + } } `; diff --git a/apps/browser-ext/src/team-state.ts b/apps/browser-ext/src/team-state.ts index def3862..8988a6a 100644 --- a/apps/browser-ext/src/team-state.ts +++ b/apps/browser-ext/src/team-state.ts @@ -9,33 +9,82 @@ import { TEAM_TOKEN as DEFAULT_TEAM_TOKEN } from './config.ts'; export const TEAM_TOKEN_KEY = 'trailhead.selectedTeamToken'; +// Cached display name for the selected team. The popup writes this when +// the user picks a team in the dropdown (and refreshes it on every team +// list fetch); the in-page pill reads it to disambiguate "Root" across +// projects ("Acme Fintech · Root" vs. "Bmw · Root"). +export const TEAM_NAME_KEY = 'trailhead.selectedTeamName'; let currentToken = DEFAULT_TEAM_TOKEN; +let currentName: string | null = null; +const subscribers = new Set<() => void>(); export function getTeamToken(): string { return currentToken; } +export function getTeamName(): string | null { + return currentName; +} + +// Notifies callers (e.g., the in-page pill) when either the team token +// or team name changes — so the pill re-renders the moment the user +// switches teams in the popup. +export function subscribeTeam(cb: () => void): () => void { + subscribers.add(cb); + return () => { + subscribers.delete(cb); + }; +} + +function notify(): void { + for (const cb of subscribers) { + try { + cb(); + } catch { + /* swallow — one bad subscriber can't break the others */ + } + } +} + export function initTeamState(): void { try { const get = (chrome as any)?.storage?.local?.get; if (typeof get !== 'function') return; - get.call((chrome as any).storage.local, TEAM_TOKEN_KEY, (out: Record) => { - const stored = out[TEAM_TOKEN_KEY]; - if (typeof stored === 'string' && stored) { - currentToken = stored; - console.info('[trailhead] team token loaded from storage'); - } - }); + get.call( + (chrome as any).storage.local, + [TEAM_TOKEN_KEY, TEAM_NAME_KEY], + (out: Record) => { + const storedToken = out[TEAM_TOKEN_KEY]; + if (typeof storedToken === 'string' && storedToken) { + currentToken = storedToken; + console.info('[trailhead] team token loaded from storage'); + } + const storedName = out[TEAM_NAME_KEY]; + if (typeof storedName === 'string' && storedName) { + currentName = storedName; + } + }, + ); const onChanged = (chrome as any)?.storage?.onChanged?.addListener; if (typeof onChanged !== 'function') return; onChanged.call( (chrome as any).storage.onChanged, (changes: Record, area: string) => { - if (area !== 'local' || !(TEAM_TOKEN_KEY in changes)) return; - const v = changes[TEAM_TOKEN_KEY]?.newValue; - currentToken = typeof v === 'string' && v ? v : DEFAULT_TEAM_TOKEN; - console.info('[trailhead] team token changed → using new token for next request'); + if (area !== 'local') return; + let changed = false; + if (TEAM_TOKEN_KEY in changes) { + const v = changes[TEAM_TOKEN_KEY]?.newValue; + currentToken = typeof v === 'string' && v ? v : DEFAULT_TEAM_TOKEN; + console.info('[trailhead] team token changed → using new token for next request'); + changed = true; + } + if (TEAM_NAME_KEY in changes) { + const v = changes[TEAM_NAME_KEY]?.newValue; + currentName = typeof v === 'string' && v ? v : null; + changed = true; + } + if (changed) notify(); }, ); } catch { diff --git a/apps/browser-ext/src/widgets/context-pill.ts b/apps/browser-ext/src/widgets/context-pill.ts index b3a97b0..9575312 100644 --- a/apps/browser-ext/src/widgets/context-pill.ts +++ b/apps/browser-ext/src/widgets/context-pill.ts @@ -1,14 +1,19 @@ -// Sticky reminder shown right above the composer whenever a wiki context -// is active. The user picked a node in the popup once; without a visual -// reminder, every send afterward silently inflates with the team context -// and they wouldn't know why their token usage jumped. The pill is the -// at-a-glance "yes, this is on" + a one-click escape hatch. +// Sticky reminder shown at the top-right of Claude.ai's chat column whenever +// a wiki context is active. The user picked a node in the popup once; +// without a visual reminder, every send afterward silently inflates with +// the team context and they wouldn't know why their token usage jumped. +// The pill is the at-a-glance "yes, this is on" + a one-click escape hatch. // -// Mounted as a sibling to the score-card so it lives inside the composer -// flow and follows it across SPA navigation. Subscribes to context-state -// so popup edits, team switches, and clears reflect live. +// Mounted on document.body with `position: fixed` so Claude's React +// reconciliation cannot tear it down. The right offset is computed from +// the composer's bounding rect — its right edge is the most reliable proxy +// for the visible chat column's right edge across Claude.ai's varying +// layouts (sidebar open vs. closed, narrow vs. wide window). A +// ResizeObserver on the composer keeps the offset accurate as the user +// resizes the window or toggles the sidebar. import { CONTEXT_PATH_KEY, getContextPath, subscribeContext } from '../context-state.ts'; +import { getTeamName, subscribeTeam } from '../team-state.ts'; import type { Selectors } from '../selectors.ts'; const PILL_ID = 'trailhead-context-pill'; @@ -16,6 +21,7 @@ const PILL_ID = 'trailhead-context-pill'; let pillEl: HTMLDivElement | null = null; let labelEl: HTMLSpanElement | null = null; let unsubscribe: (() => void) | null = null; +let unsubscribeTeam: (() => void) | null = null; function buildPill(): HTMLDivElement { const pill = document.createElement('div'); @@ -61,28 +67,52 @@ function refresh(): void { pillEl.hidden = true; return; } - labelEl.textContent = path; + // Root-context sentinel: the popup persists '/' for the repo-root node + // (its real wiki path is the empty string, which clashes with our + // truthy "is context active?" check). Render it as "Root" so the pill + // doesn't show a bare slash. + const pathLabel = path === '/' ? 'Root' : path; + // Prepend the team's display name when available — disambiguates + // ambiguous labels like "Root" across multiple projects. Falls back + // to just the path when no team name has been cached yet (e.g., + // user is on the default demo team and never opened the team picker). + const teamName = getTeamName(); + labelEl.textContent = teamName ? `${teamName} · ${pathLabel}` : pathLabel; pillEl.hidden = false; } +// Position is now static (centered horizontally, bottom: 100px) — see +// styles.ts. No dynamic offset functions needed; CSS handles it. + export function mountContextPill(sel: Selectors): () => void { // Already mounted? (Re-init can fire during health-check recoveries.) // Detach the old node before rebuilding so we don't leave orphans in the DOM. if (pillEl && pillEl.isConnected) pillEl.remove(); pillEl = buildPill(); - // Mount just before the score-card anchor — the pill sits at the top of - // the composer area, above any score-card output. - sel.scoreCardAnchor.insertAdjacentElement('beforebegin', pillEl); + // Mount on body so Claude's React renderer can't sweep us away when it + // reconciles the chat tree. The styles.ts rule for #trailhead-context-pill + // sets position: fixed + top: 16px; the right offset is set inline below. + document.body.appendChild(pillEl); refresh(); + // Position is fully static via CSS (centered horizontally, bottom: 100px) + // — no resize observer or interval needed. `sel` is kept for API + // compatibility with the mount call site. + void sel; if (unsubscribe) unsubscribe(); unsubscribe = subscribeContext(refresh); + if (unsubscribeTeam) unsubscribeTeam(); + unsubscribeTeam = subscribeTeam(refresh); return () => { if (unsubscribe) { unsubscribe(); unsubscribe = null; } + if (unsubscribeTeam) { + unsubscribeTeam(); + unsubscribeTeam = null; + } if (pillEl && pillEl.isConnected) pillEl.remove(); pillEl = null; labelEl = null; diff --git a/apps/browser-ext/src/widgets/improve-chat.ts b/apps/browser-ext/src/widgets/improve-chat.ts index 043179e..6f48ff2 100644 --- a/apps/browser-ext/src/widgets/improve-chat.ts +++ b/apps/browser-ext/src/widgets/improve-chat.ts @@ -1,41 +1,79 @@ -// Improve-chat widget — Gemini-driven multi-turn prompt coach. -// Spec: docs/superpowers/specs/2026-04-26-improve-widget-design.md +// Improve-chat widget — drives the multi-round educational score arc +// against the API's POST /coach endpoint. +// +// Why /coach (not /improve): /coach uses the project's polished MCP-server +// tactic — curated DIMENSION_TEACH blocks per round (single lowest dim +// targeted at a time), server-side no-progress detection, success and skip +// reveals showing the score arc. The user *writes* the better prompt +// themselves by appending to it across rounds; this is the educational +// path the rest of Trailhead uses. /improve was a one-off LLM rewrite +// that bypassed that learning loop. // // Card layout (single, persistent close button): // // ┌─────────────────────────────────────────┐ -// │ [×] │ ← X always cancels +// │ [×] │ // ├─────────────────────────────────────────┤ -// │ │ ← stage-specific body +// │ │ // └─────────────────────────────────────────┘ // // Stages: -// 1. choice — opening screen with a single "Start coaching" button. -// (The wiki-context option lives in the extension popup, -// not here.) -// 2. asking — chat thread + input row, Send only. -// 3. preview — polished prompt + Use this. Use this writes the prompt -// into Claude's composer and marks it approved so the -// user's next Enter goes straight to Claude. -// 4. error — coach unreachable; "Use template instead" fallback. - -import type { ImproveResponse, ImproveTurn, MissingHints } from '@trailhead/shared'; -import { improve as apiImprove } from '../api.ts'; +// 1. choice — "Start coaching" intro screen. +// 2. coaching — chat thread of teach-blocks + user replies, input row, +// actions row with disabled "Use this prompt" affordance +// plus "I'm done" bail-out. +// 3. done — success or skip reveal text in the rationale block, +// the user's evolved prompt in the preview body, and an +// enabled "Use this prompt" + "I'm done" action row. +// 4. error — coach unreachable; "I'm done" + "Use template instead". + +import type { + CoachNextRoundInputs, + CoachResponse, + MissingHints, +} from '@trailhead/shared'; +import { coach as apiCoach } from '../api.ts'; import { USER_ID } from '../config.ts'; import { writePrompt, type Selectors } from '../selectors.ts'; import { resetCard } from '../score-card.ts'; import { augmentAndSend, markApproved } from '../send-intercept.ts'; -export const IMPROVE_TURN_CAP = 5; +interface ChatTurn { + role: 'user' | 'coach'; + text: string; +} -type ImproveState = +type CoachState = | { stage: 'choice' } - | { stage: 'asking'; history: ImproveTurn[]; pending: false } - | { stage: 'asking'; history: ImproveTurn[]; pending: true; command: 'next' | 'finalize' } - | { stage: 'preview'; history: ImproveTurn[]; polished: string; rationale?: string } - | { stage: 'error'; history: ImproveTurn[]; message: string } + | { + stage: 'coaching'; + bubbles: ChatTurn[]; + currentPrompt: string; + next: CoachNextRoundInputs | null; + pending: false; + } + | { + stage: 'coaching'; + bubbles: ChatTurn[]; + currentPrompt: string; + next: CoachNextRoundInputs | null; + pending: true; + } + | { + stage: 'done'; + bubbles: ChatTurn[]; + currentPrompt: string; + revealText: string; + mode: 'score' | 'skip_reveal' | 'augment'; + } + | { + stage: 'error'; + bubbles: ChatTurn[]; + currentPrompt: string; + message: string; + } | { stage: 'cancelled' } - | { stage: 'done' }; + | { stage: 'finished' }; interface ChatRefs { root: HTMLDivElement; @@ -51,31 +89,31 @@ interface ChatRefs { inputRow: HTMLDivElement; input: HTMLTextAreaElement; sendBtn: HTMLButtonElement; - // Preview body (the polished prompt — preview stage only) + // Done — reveal text shown above the user's evolved prompt + previewRationale: HTMLDivElement; previewBody: HTMLPreElement; - // Universal "Use AI prompt" row: visible in asking + preview, disabled - // until a polished prompt arrives. + // Universal action wrap. useThisRow holds primary "Use this prompt" (enabled + // only in done). previewActionsRow holds secondary "I'm done" (enabled + // throughout coaching + done). useThisRow: HTMLDivElement; useThisBtn: HTMLButtonElement; - // I'm done lives in its own row, visible in preview only. previewActionsRow: HTMLDivElement; previewImDoneBtn: HTMLButtonElement; // Error errorBody: HTMLDivElement; errorMsg: HTMLDivElement; + errImDoneBtn: HTMLButtonElement; useTemplateBtn: HTMLButtonElement; } -function userReplyCount(history: ImproveTurn[]): number { - return history.filter((t) => t.role === 'user').length; -} - export function openImproveChat( sel: Selectors, originalPrompt: string, - missing: MissingHints, + // missing is no longer used — /coach figures out the lowest dim itself. + // Kept in the signature so score-card.ts callers don't break. + _missing: MissingHints, ): void { - console.info('[trailhead] openImproveChat: originalPrompt=', originalPrompt.slice(0, 60), 'missing=', Object.keys(missing)); + console.info('[trailhead] openImproveChat: originalPrompt=', originalPrompt.slice(0, 60)); const cardEl = document.getElementById('trailhead-score-card') as HTMLDivElement | null; if (!cardEl) { console.warn('[trailhead] openImproveChat: no #trailhead-score-card in DOM'); @@ -83,69 +121,103 @@ export function openImproveChat( } const refs = buildChatDom(cardEl); - let state: ImproveState = { stage: 'choice' }; + let state: CoachState = { stage: 'choice' }; - const setState = (next: ImproveState): void => { + const setState = (next: CoachState): void => { state = next; render(refs, state); - if (state.stage === 'asking' && state.pending) { - void runImproveCall(state.history, state.command); + if (state.stage === 'coaching' && state.pending) { + void runCoachCall(state.currentPrompt, state.next); } - if (state.stage === 'cancelled' || state.stage === 'done') { + if (state.stage === 'cancelled' || state.stage === 'finished') { teardown(refs); } }; - const runImproveCall = async (history: ImproveTurn[], command: 'next' | 'finalize'): Promise => { - const res = await apiImprove({ - original_prompt: originalPrompt, - missing, - history, - command, - user_id: USER_ID, - }); - handleApiResponse(res); + const runCoachCall = async ( + prompt: string, + nextInputs: CoachNextRoundInputs | null, + ): Promise => { + const body = nextInputs + ? { + prompt, + user_id: USER_ID, + mode: 'score' as const, + original_prompt: nextInputs.original_prompt, + original_dimensions: nextInputs.original_dimensions, + previous_dimensions: nextInputs.previous_dimensions, + round: nextInputs.round, + } + : { prompt, user_id: USER_ID, mode: 'score' as const }; + const res = await apiCoach(body); + handleCoachResponse(res); }; - const handleApiResponse = (res: ImproveResponse | null): void => { - if (state.stage !== 'asking' || !state.pending) return; + const handleCoachResponse = (res: CoachResponse | null): void => { + if (state.stage !== 'coaching' || !state.pending) return; if (!res) { - setState({ stage: 'error', history: state.history, message: 'Couldn’t reach the coach.' }); + setState({ + stage: 'error', + bubbles: state.bubbles, + currentPrompt: state.currentPrompt, + message: 'Couldn’t reach the coach.', + }); return; } - if (res.kind === 'question') { - const history = [...state.history, { role: 'assistant' as const, text: res.text }]; - setState({ stage: 'asking', history, pending: false }); + // proceed=false → another teaching round. Append the coach's text as an + // assistant bubble and wait for the user's reply. + if (!res.proceed) { + const bubbles: ChatTurn[] = res.text.trim() + ? [...state.bubbles, { role: 'coach', text: res.text.trim() }] + : state.bubbles; + setState({ + stage: 'coaching', + bubbles, + currentPrompt: state.currentPrompt, + next: res.next_round_inputs ?? null, + pending: false, + }); return; } + // proceed=true → reached an exit (success, skip_reveal, or already-strong + // first-round). Move to the done state with the reveal text and the + // user's evolved prompt as the result. setState({ - stage: 'preview', - history: state.history, - polished: res.polished, - rationale: res.rationale, + stage: 'done', + bubbles: state.bubbles, + currentPrompt: state.currentPrompt, + revealText: res.text.trim(), + mode: res.mode, }); }; const sendUserReply = (): void => { - if (state.stage !== 'asking' || state.pending) return; + if (state.stage !== 'coaching' || state.pending) return; const text = refs.input.value.trim(); if (!text) return; - const history = [...state.history, { role: 'user' as const, text }]; refs.input.value = ''; - const replies = userReplyCount(history); - const command: 'next' | 'finalize' = replies >= IMPROVE_TURN_CAP ? 'finalize' : 'next'; - setState({ stage: 'asking', history, pending: true, command }); + // Concatenate the user's reply onto their prompt (the MCP coaching + // pattern — the user's own writing becomes the final prompt). A single + // space separator keeps the prompt readable and matches the directive's + // "user's reply concatenated to the previous prompt" instruction. + const newPrompt = `${state.currentPrompt} ${text}`; + setState({ + stage: 'coaching', + bubbles: [...state.bubbles, { role: 'user', text }], + currentPrompt: newPrompt, + next: state.next, + pending: true, + }); }; - // "I'm done" — bail out of the widget and use the user's ORIGINAL - // prompt as it stands in the composer. We mark it as approved so the - // next Enter sends straight to Claude without re-opening the score - // card. Used from the chat (asking) and preview stages. + // "I'm done" — bail to the user's ORIGINAL prompt as it stands in the + // composer. We mark it as approved so the next Enter sends straight to + // Claude without re-opening the score card. const onImDone = (): void => { - console.info('[trailhead] improve: I\'m done clicked, originalPrompt=', originalPrompt.slice(0, 60)); + console.info('[trailhead] coach: I’m done clicked, originalPrompt=', originalPrompt.slice(0, 60)); markApproved(originalPrompt); sel.textarea.focus(); - setState({ stage: 'done' }); + setState({ stage: 'finished' }); }; // ----- Header X (single cancel for the whole widget) @@ -154,7 +226,13 @@ export function openImproveChat( // ----- Choice screen refs.startBtn.addEventListener('click', () => { if (state.stage !== 'choice') return; - setState({ stage: 'asking', history: [], pending: true, command: 'next' }); + setState({ + stage: 'coaching', + bubbles: [], + currentPrompt: originalPrompt, + next: null, + pending: true, + }); requestAnimationFrame(() => refs.input.focus()); }); @@ -171,26 +249,26 @@ export function openImproveChat( } }); - // ----- Preview + // ----- Done — "Use this prompt" writes the user's evolved prompt into + // the composer and pre-approves it so the next Enter sends through. refs.useThisBtn.addEventListener('click', () => { - if (state.stage !== 'preview') return; - const polished = state.polished; + if (state.stage !== 'done') return; + const finalPrompt = state.currentPrompt; try { - writePrompt(sel.textarea, polished); + writePrompt(sel.textarea, finalPrompt); sel.textarea.focus(); - // Tell send-intercept this exact text is pre-approved so the user's - // next Enter sends straight to Claude (no score-card, no widget). - markApproved(polished); + markApproved(finalPrompt); } catch (err) { console.warn('[trailhead] writePrompt failed', err); } - setState({ stage: 'done' }); + setState({ stage: 'finished' }); }); refs.previewImDoneBtn.addEventListener('click', onImDone); // ----- Error + refs.errImDoneBtn.addEventListener('click', onImDone); refs.useTemplateBtn.addEventListener('click', () => { - setState({ stage: 'done' }); + setState({ stage: 'finished' }); void augmentAndSend(); }); @@ -215,13 +293,13 @@ function buildChatDom(card: HTMLDivElement): ChatRefs { closeBtn.textContent = '×'; header.append(headerTitle, closeBtn); - // Choice screen — single Start button now that wiki context lives in - // the popup. + // Choice screen const choice = document.createElement('div'); choice.className = 'trailhead-improve-choice'; const choiceIntro = document.createElement('div'); choiceIntro.className = 'trailhead-improve-choice-intro'; - choiceIntro.textContent = 'Want me to coach this prompt with a few quick questions?'; + choiceIntro.textContent = + 'Walk through a few targeted questions. Your replies build a stronger version of your own prompt — and you’ll learn the rubric the coach scores against.'; const choiceActions = document.createElement('div'); choiceActions.className = 'trailhead-improve-choice-actions'; const startBtn = document.createElement('button'); @@ -246,35 +324,42 @@ function buildChatDom(card: HTMLDivElement): ChatRefs { sendBtn.textContent = 'Send'; inputRow.append(input, sendBtn); - // Preview body (the polished prompt) — preview stage only. + // Reveal block — the success/skip reveal text rendered above the final + // prompt. Shares the same accent-tinted style as the old rationale slot. + const previewRationale = document.createElement('div'); + previewRationale.className = 'trailhead-improve-preview-rationale'; + previewRationale.hidden = true; + + // Final prompt (user's own evolved prompt, not an LLM rewrite). Shown in + // the done stage so the user can see what they’ll send if they accept. const previewBody = document.createElement('pre'); previewBody.className = 'trailhead-improve-preview-body'; previewBody.hidden = true; - // Universal "Use AI prompt" row — visible across asking + preview so - // the action is always discoverable. Stays disabled until polished - // arrives, then lights up. + // Action rows. useThisRow = primary "Use this prompt" (enabled only in + // done). previewActionsRow = secondary "I'm done" (enabled in coaching + + // done so the user can bail to the original prompt at any moment). const useThisRow = document.createElement('div'); useThisRow.className = 'trailhead-improve-preview-actions'; const useThisBtn = document.createElement('button'); useThisBtn.type = 'button'; useThisBtn.className = 'is-primary'; useThisBtn.disabled = true; - useThisBtn.title = 'Available once the coach has polished your prompt.'; - useThisBtn.textContent = 'Use AI prompt'; + useThisBtn.title = 'Available once coaching wraps up.'; + useThisBtn.textContent = 'Use this prompt'; useThisRow.append(useThisBtn); - // Preview-only actions row (currently just I'm done). const previewActionsRow = document.createElement('div'); previewActionsRow.className = 'trailhead-improve-preview-actions'; previewActionsRow.hidden = true; const previewImDoneBtn = document.createElement('button'); previewImDoneBtn.type = 'button'; - previewImDoneBtn.title = 'Discard the polished version — use the prompt I originally typed.'; - previewImDoneBtn.textContent = 'I’m done'; + previewImDoneBtn.title = + 'Discard coaching changes — use my original prompt as I typed it.'; + previewImDoneBtn.textContent = 'Keep original'; previewActionsRow.append(previewImDoneBtn); - // Error + // Error stage actions: bail to original or accept the legacy template. const errorBody = document.createElement('div'); errorBody.className = 'trailhead-improve-error'; errorBody.hidden = true; @@ -282,21 +367,32 @@ function buildChatDom(card: HTMLDivElement): ChatRefs { errorMsg.className = 'trailhead-improve-error-msg'; const errActions = document.createElement('div'); errActions.className = 'trailhead-improve-error-actions'; + const errImDoneBtn = document.createElement('button'); + errImDoneBtn.type = 'button'; + errImDoneBtn.title = 'Discard the coaching attempt and use my original prompt as-is.'; + errImDoneBtn.textContent = 'Keep original'; const useTemplateBtn = document.createElement('button'); useTemplateBtn.type = 'button'; useTemplateBtn.className = 'is-primary'; useTemplateBtn.textContent = 'Use template instead'; - errActions.append(useTemplateBtn); + errActions.append(errImDoneBtn, useTemplateBtn); errorBody.append(errorMsg, errActions); + // Right-aligned wrapper for the two action rows. In coaching: only + // "I'm done" is visible. In done: "I'm done" + enabled "Use this prompt" + // sit side-by-side on a single horizontal line, primary on the right. + const actionsWrap = document.createElement('div'); + actionsWrap.className = 'trailhead-improve-actions-wrap'; + actionsWrap.append(previewActionsRow, useThisRow); + card.append( header, choice, thread, inputRow, + previewRationale, previewBody, - useThisRow, - previewActionsRow, + actionsWrap, errorBody, ); @@ -304,69 +400,92 @@ function buildChatDom(card: HTMLDivElement): ChatRefs { root: card, header, headerTitle, closeBtn, choice, startBtn, thread, inputRow, input, sendBtn, - previewBody, useThisRow, useThisBtn, previewActionsRow, previewImDoneBtn, - errorBody, errorMsg, useTemplateBtn, + previewRationale, previewBody, useThisRow, useThisBtn, previewActionsRow, previewImDoneBtn, + errorBody, errorMsg, errImDoneBtn, useTemplateBtn, }; } -function render(refs: ChatRefs, state: ImproveState): void { +function render(refs: ChatRefs, state: CoachState): void { + const isCoaching = state.stage === 'coaching'; + const isDone = state.stage === 'done'; + refs.choice.hidden = state.stage !== 'choice'; - refs.thread.hidden = state.stage !== 'asking'; - refs.inputRow.hidden = state.stage !== 'asking'; - refs.previewBody.hidden = state.stage !== 'preview'; - // Use AI prompt row is visible across asking + preview. - refs.useThisRow.hidden = state.stage !== 'asking' && state.stage !== 'preview'; - // I'm done row stays preview-only. - refs.previewActionsRow.hidden = state.stage !== 'preview'; + refs.thread.hidden = !isCoaching && !isDone; + refs.inputRow.hidden = !isCoaching; + refs.previewRationale.hidden = true; + refs.previewBody.hidden = !isDone; + refs.useThisRow.hidden = !isCoaching && !isDone; + refs.previewActionsRow.hidden = !isCoaching && !isDone; refs.errorBody.hidden = state.stage !== 'error'; + // Header reflects the current stage so the user always knows where they + // are in the flow. if (state.stage === 'choice') { refs.headerTitle.textContent = 'Improve your prompt'; - } else if (state.stage === 'asking') { - refs.headerTitle.textContent = 'Coaching…'; - } else if (state.stage === 'preview') { - refs.headerTitle.textContent = 'Polished prompt ready'; + } else if (state.stage === 'coaching') { + refs.headerTitle.textContent = state.pending ? 'Coaching…' : 'Your turn'; + } else if (state.stage === 'done') { + refs.headerTitle.textContent = + state.mode === 'skip_reveal' ? 'Coaching paused' : 'Coaching complete'; } else if (state.stage === 'error') { refs.headerTitle.textContent = 'Coach unavailable'; } - if (state.stage === 'asking') { + // Render chat bubbles in coaching + done so the conversation stays visible + // as a learning record even after the user reaches the reveal. + if (isCoaching || isDone) { refs.thread.replaceChildren(); - for (const t of state.history) { + const bubbles = state.stage === 'coaching' || state.stage === 'done' ? state.bubbles : []; + for (const t of bubbles) { const bubble = document.createElement('div'); - bubble.className = `trailhead-bubble trailhead-bubble--${t.role}`; - bubble.textContent = (t.role === 'assistant' ? '🤖 ' : '👤 ') + t.text; + const role = t.role === 'coach' ? 'assistant' : 'user'; + bubble.className = `trailhead-bubble trailhead-bubble--${role}`; + bubble.textContent = (t.role === 'coach' ? '🧑‍🏫 ' : '👤 ') + t.text; refs.thread.appendChild(bubble); } - if (state.pending) { + if (isCoaching && state.pending) { const bubble = document.createElement('div'); bubble.className = 'trailhead-bubble trailhead-bubble--assistant trailhead-bubble--pending'; bubble.textContent = '…'; refs.thread.appendChild(bubble); } refs.thread.scrollTop = refs.thread.scrollHeight; + } + if (isCoaching) { const pending = state.pending; refs.input.disabled = pending; refs.sendBtn.disabled = pending; - // "I'm done" stays enabled even while a request is in flight — the - // user is allowed to bail at any moment. Pending API responses are - // ignored once we transition to 'done'. refs.input.placeholder = pending ? 'Coach is thinking…' : 'Type your answer…'; + // Use this prompt is disabled until coaching reaches a reveal. + refs.useThisBtn.disabled = true; + refs.useThisBtn.title = 'Available once coaching wraps up.'; } - if (state.stage === 'preview') { - refs.previewBody.textContent = state.polished; - // Enable Use AI prompt now that we have a polished prompt to use. - // Defensive empty-check stays in case the API ever returns "". - refs.useThisBtn.disabled = !state.polished?.trim(); - refs.useThisBtn.title = 'Drop the AI-polished prompt into Claude’s composer.'; - } else { - // Asking (and any pre-preview state) — keep the button disabled - // until the coach finalizes. - refs.useThisBtn.disabled = true; - refs.useThisBtn.title = 'Available once the coach has polished your prompt.'; + if (isDone) { + // Reveal text (success or skip reveal) shown as the lesson recap above + // the user's evolved prompt. Server may return empty text on a + // first-round-already-strong shortcut; only render the block when there + // is something to show. + const text = state.revealText; + if (text && text.trim()) { + refs.previewRationale.replaceChildren(); + const label = document.createElement('span'); + label.className = 'trailhead-improve-preview-rationale-label'; + label.textContent = state.mode === 'skip_reveal' ? 'How you could have written it' : 'What changed'; + const body = document.createElement('span'); + body.className = 'trailhead-improve-preview-rationale-text'; + body.textContent = text; + refs.previewRationale.append(label, body); + refs.previewRationale.hidden = false; + } + refs.previewBody.textContent = state.currentPrompt; + refs.useThisBtn.disabled = !state.currentPrompt.trim(); + refs.useThisBtn.title = 'Drop your evolved prompt into Claude’s composer.'; + refs.input.disabled = true; + refs.sendBtn.disabled = true; } + if (state.stage === 'error') { refs.errorMsg.textContent = state.message; } diff --git a/apps/mcp-server/src/coaching-directive.md b/apps/mcp-server/src/coaching-directive.md index 4218dd6..3561ade 100644 --- a/apps/mcp-server/src/coaching-directive.md +++ b/apps/mcp-server/src/coaching-directive.md @@ -42,7 +42,7 @@ this function do", "how should I structure X". - all four fields from `next_round_inputs` (`original_prompt`, `original_dimensions`, `previous_dimensions`, `round`) echoed back unchanged - Loop. The server enforces the cap (3 rounds) and bails on no-progress. + Loop. The server enforces the cap (5 rounds) and bails on no-progress. NEVER skip `coach` to "save time" — the score-arc IS the user-facing product. The user is here to learn the rubric; producing an answer diff --git a/apps/mcp-server/src/tools.ts b/apps/mcp-server/src/tools.ts index 817e5e1..09efc83 100644 --- a/apps/mcp-server/src/tools.ts +++ b/apps/mcp-server/src/tools.ts @@ -221,9 +221,9 @@ export function registerCoach(server: McpServer, client: ApiClient): void { .number() .int() .min(1) - .max(3) + .max(5) .optional() - .describe('Round 2+ only. Echoed from next_round_inputs.round. Server clamps to [1, 3].'), + .describe('Round 2+ only. Echoed from next_round_inputs.round. Server clamps to [1, 5].'), }, outputSchema: { proceed: z.boolean(), diff --git a/packages/scoring/src/reveal-render.d.mts b/packages/scoring/src/reveal-render.d.mts index 6e0e110..70e4be6 100644 --- a/packages/scoring/src/reveal-render.d.mts +++ b/packages/scoring/src/reveal-render.d.mts @@ -5,6 +5,14 @@ export interface RenderTeachBlockArgs { targetScore: number; strongExample: string; previousLowestDim?: Dimension; + // Gemini-generated round-2+ acknowledgment of the user's last edit. + // Empty string or undefined → fall back to the static "You addressed X" + // line driven by `previousLowestDim`. + acknowledgment?: string; + // Gemini-generated one-liner naming the principle the strong example + // demonstrates. Rendered as "Why it works: " right after the + // example. Omitted when empty / undefined. + tip?: string; } export declare function renderTeachBlock(args: RenderTeachBlockArgs): string; @@ -16,6 +24,9 @@ export interface RenderSuccessRevealArgs { originalDimensions: DimensionScores; finalDimensions: DimensionScores; maxRoundsHit?: boolean; + // Gemini-generated end-of-session takeaway. Appended after the static + // score arc; omitted on helper failure (fail-open). + summary?: string; } export declare function renderSuccessReveal(args: RenderSuccessRevealArgs): string; @@ -24,5 +35,7 @@ export interface RenderSkipRevealArgs { originalDimensions: DimensionScores; reason: 'skip' | 'no_progress'; noProgressDim?: Dimension; + // Same as RenderSuccessRevealArgs.summary. + summary?: string; } export declare function renderSkipReveal(args: RenderSkipRevealArgs): string; diff --git a/packages/scoring/src/reveal-render.mjs b/packages/scoring/src/reveal-render.mjs index f48bc2f..dd8717d 100644 --- a/packages/scoring/src/reveal-render.mjs +++ b/packages/scoring/src/reveal-render.mjs @@ -48,6 +48,8 @@ export function renderTeachBlock({ targetScore, strongExample, previousLowestDim, + acknowledgment, + tip, }) { const tpl = DIMENSION_TEACH[targetDim]; if (!tpl) { @@ -55,7 +57,13 @@ export function renderTeachBlock({ } const lines = []; - if (previousLowestDim && previousLowestDim !== targetDim) { + // Round 2+ acknowledgment of the user's last edit. Gemini-generated, so + // it can specifically name what they added; falls back to the static + // "You addressed X" line if the helper failed (empty string). + if (acknowledgment && acknowledgment.trim()) { + lines.push(acknowledgment.trim()); + lines.push(''); + } else if (previousLowestDim && previousLowestDim !== targetDim) { const prevTpl = DIMENSION_TEACH[previousLowestDim]; const prevLabel = prevTpl ? prevTpl.title.toLowerCase() : previousLowestDim.replace(/_/g, ' '); lines.push(`You addressed ${prevLabel}. ${tpl.title.toLowerCase()} is the next gap.`); @@ -67,6 +75,9 @@ export function renderTeachBlock({ if (strongExample && strongExample.trim()) { lines.push(''); lines.push(`Strong example: "${strongExample.trim()}"`); + if (tip && tip.trim()) { + lines.push(`Why it works: ${tip.trim()}`); + } } lines.push(''); lines.push(tpl.question); @@ -75,7 +86,7 @@ export function renderTeachBlock({ // ============================================================================= // renderSuccessReveal — fires when round >= 2 AND overall >= 7, OR when the -// pipeline forcibly exits at round 3 (set `maxRoundsHit: true`). +// pipeline forcibly exits at COACH_MAX_ROUNDS (set `maxRoundsHit: true`). // ============================================================================= export function renderSuccessReveal({ @@ -86,6 +97,7 @@ export function renderSuccessReveal({ originalDimensions, finalDimensions, maxRoundsHit, + summary, }) { const improved = topImprovedDims(originalDimensions, finalDimensions); const calloutLine = improved.length @@ -96,14 +108,21 @@ export function renderSuccessReveal({ ? `(coached: ${originalOverall} → ${finalOverall}, max rounds reached)` : `(coached: ${originalOverall} → ${finalOverall})`; - return [ + const lines = [ header, 'Your prompt grew:', ` "${truncate(originalPrompt, 80)}"`, ' →', ` "${truncate(finalPrompt, 200)}"`, calloutLine, - ].join('\n'); + ]; + // Gemini-written closing recap. Fail-open: when the helper returned "", + // the static block above is still informative on its own. + if (summary && summary.trim()) { + lines.push(''); + lines.push(summary.trim()); + } + return lines.join('\n'); } // ============================================================================= @@ -121,6 +140,7 @@ export function renderSkipReveal({ originalDimensions, reason, noProgressDim, + summary, }) { const wouldHaveImproved = DIMS.filter((d) => (originalDimensions?.[d] ?? 0) < 5); const calloutLine = wouldHaveImproved.length @@ -139,5 +159,11 @@ export function renderSkipReveal({ const lines = [prefix, ` "${(strongRewrite ?? '').trim()}"`]; if (calloutLine) lines.push(calloutLine); + // Same fail-open pattern as renderSuccessReveal — Gemini-written takeaway + // appended after the templated arc, omitted on helper failure. + if (summary && summary.trim()) { + lines.push(''); + lines.push(summary.trim()); + } return lines.join('\n'); } diff --git a/packages/scoring/src/teach-prompt.mjs b/packages/scoring/src/teach-prompt.mjs index 83fd99e..51da789 100644 --- a/packages/scoring/src/teach-prompt.mjs +++ b/packages/scoring/src/teach-prompt.mjs @@ -10,7 +10,13 @@ // Same guardrails as SCORE_SYSTEM_PROMPT (locked after the 2026-04-25 Flash // repetition incident): short declarative output, schema-enforced shape, // no follow-up questions, no listed alternatives. -export const TEACH_SYSTEM_PROMPT = `You rewrite a developer's draft prompt so it scores 9 or higher on a SET of named dimensions, while preserving the user's topic and intent. +// +// 2026-04-26 educational layer: the rewrite now ships with an optional +// one-line `tip` naming the prompt-engineering principle the rewrite +// demonstrates. The tip is hard-capped at 100 chars to stay inside the +// same anti-loop discipline as the score `missing` hints. Schema +// enforcement remains the wire-level guarantee against runaway output. +export const TEACH_SYSTEM_PROMPT = `You rewrite a developer's draft prompt so it scores 9 or higher on a SET of named dimensions, while preserving the user's topic and intent. You also state ONE short tip naming the prompt-engineering principle the rewrite demonstrates. The five dimensions are: - goal_clarity — desired outcome stated unambiguously @@ -20,12 +26,13 @@ The five dimensions are: - output_specification — requests the desired output shape (only the changed function, full file, diff, etc.) Return JSON only, no prose: -{ "rewritten_prompt": } +{ "rewritten_prompt": , "tip": } Rules — MUST follow: - Preserve the user's topic and intent. Do NOT invent constraints or features the user did not imply. - The rewrite must read like the same user on a better day, not a different person. -- Keep it short — one to three sentences max. +- Keep the rewrite short — one to three sentences max. - For each named target dimension, the rewrite must demonstrate that dimension concretely. -- NEVER ask follow-up questions, list alternatives, or include explanation prose. +- "tip" is ONE short declarative sentence under 100 characters naming the principle (e.g. "Naming the file grounds the answer in real code instead of plausible guesses."). +- NEVER ask follow-up questions, list alternatives, or include explanation prose anywhere in the output. - NEVER use rhetorical "What if..." / "How could..." phrasing — these trigger a repetition loop.`; diff --git a/packages/shared/types.ts b/packages/shared/types.ts index 362c995..47c90f2 100644 --- a/packages/shared/types.ts +++ b/packages/shared/types.ts @@ -243,7 +243,7 @@ export interface CoachRequest { original_prompt?: string; original_dimensions?: DimensionScores; previous_dimensions?: DimensionScores; - round?: number; // 1-indexed; server clamps to [1, 3] + round?: number; // 1-indexed; server clamps to [1, 5] // Optional team-wiki context, same semantics as ScoreRequest.context_path. context_path?: string;