From cbb4e55a8195f9625d0d26bbe393f1b76687eeeb Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 20 Aug 2026 14:33:23 +0800 Subject: [PATCH 1/9] feat(1.0.0): a first-run user should not have to leave the panel to find out how MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel assumed its reader had already read the README. For a 1.0.0 release that is the wrong assumption to ship: the most likely reader is someone whose first run went nowhere because Ollama is not running, or who is four minutes into a task and does not know whether that is normal. `components/guideCard.js` holds both halves — the four setup steps and, more importantly, what to expect once they are done. The expectations are blunt on purpose. A user told to expect ChatGPT concludes the extension is broken; one told a task takes 1-5 minutes on a laptop waits for it. It renders locally rather than posting to the host, because unlike every other control here it has nothing to ask for: the text is identical on every machine. And it is a card in the transcript rather than a modal, so it can be read beside the run that prompted it instead of covering it. --- app/webview/components/guideCard.js | 181 ++++++++++++++++++++++++++++ app/webview/index.html | 15 +++ app/webview/main.js | 34 ++++++ app/webview/style.css | 83 +++++++++++++ test/unit/webviewComponents.test.js | 98 ++++++++++++++- 5 files changed, 410 insertions(+), 1 deletion(-) create mode 100644 app/webview/components/guideCard.js diff --git a/app/webview/components/guideCard.js b/app/webview/components/guideCard.js new file mode 100644 index 0000000..eaf6737 --- /dev/null +++ b/app/webview/components/guideCard.js @@ -0,0 +1,181 @@ +/** + * The setup guide — what to install, and what the thing actually does once installed. + * + * ## Why this is static text in the webview + * + * Every other control here posts to the host, because the host is the only side that + * can do anything. This one has nothing to ask for: the guide is the same sentences on + * every machine, in every workspace, whether or not Ollama is running. Routing it + * through a message would buy a protocol and a round-trip for a string constant. + * + * ## Why it is a card in the transcript rather than a modal + * + * The most likely reader is someone whose first run did not go the way they expected — + * a model that has not been pulled, a task that is taking four minutes, a write that + * was refused. They need the guide *beside* the thing that confused them, and they need + * to keep scrolling back to it. A modal takes the transcript away to show it. + * + * The content is deliberately blunt about the trade. A first-time user who is told to + * expect ChatGPT and gets a 1B model will conclude the extension is broken; one who is + * told a task takes one to five minutes on a laptop will wait for it. + * + * @module webview/components/guideCard + */ + +/** + * @typedef {object} GuideStep + * @property {string} title + * @property {string} detail + * @property {string} [command] A line to paste into a terminal, if the step has one. + */ + +/** @type {GuideStep[]} */ +const SETUP = [ + { + title: 'Install Ollama and leave it running', + detail: + 'Ollama is the free program that runs the AI on your own machine. Download it from ollama.com. It has no window — it sits in your system tray or menu bar, and that is normal.', + }, + { + title: 'Download one model', + detail: + 'Paste this into a terminal. It downloads a few gigabytes once, then never again. On 8 GB of RAM use llama3.2:1b instead.', + command: 'ollama pull gemma4:e2b', + }, + { + title: 'Open a folder', + detail: + 'File → Open Folder. This is required, not a suggestion: HirayaCoder confines every file operation to the folder you opened, so with no folder open there is nowhere it is allowed to work.', + }, + { + title: 'Pick your model above and start typing', + detail: + 'The dropdown in this header lists what Ollama has installed. Ask for one thing at a time — "add a delete button to index.html" goes much better than "build me a social network".', + }, +]; + +/** @type {Array<{title: string, detail: string}>} */ +const EXPECT = [ + { + title: 'It is slower than you are used to', + detail: + 'A task takes 1–5 minutes on a laptop with no graphics card, 20–60 seconds with one. The step panel shows you each action as it happens so you can tell "thinking" from "stuck" — and stop it when it is the second one.', + }, + { + title: 'Nothing is saved until you approve it', + detail: + 'Every write shows you a diff first. Turn on Auto Edit from the Permissions button once you trust it; deleting a file asks even then.', + }, + { + title: 'A refusal is usually the checks working', + detail: + 'Writes that would truncate a file, drop an export, or leave a stub inside a function are blocked before they reach the disk. Ask again — it usually gets it right the second time.', + }, + { + title: 'Small models are capable, not clever', + detail: + 'One file, one feature, one fix at a time is where a local model is genuinely good. Handed a whole application it will write plausible files that do not run together. That is a real limit, not a setting you have missed.', + }, + { + title: 'Three modes, and Agent is the right default', + detail: + 'Agent reads and writes. Plan looks without touching anything and hands back a checklist you can edit and then run. Ask answers a question with no tools at all. You do not need to switch to Ask to ask something — Agent notices a question and just answers it.', + }, +]; + +/** + * One titled paragraph, optionally with a copyable-looking command under it. + * + * @param {string} tag The element for the title — `li` items carry their own marker. + * @param {{title: string, detail: string, command?: string}} entry + * @returns {HTMLElement} + */ +function renderEntry(tag, entry) { + const item = document.createElement(tag); + item.className = 'guide-item'; + + const title = document.createElement('span'); + title.className = 'guide-item-title'; + title.textContent = entry.title; + item.appendChild(title); + + const detail = document.createElement('span'); + detail.className = 'guide-item-detail'; + detail.textContent = entry.detail; + item.appendChild(detail); + + if (entry.command) { + const command = document.createElement('code'); + command.className = 'guide-command'; + command.textContent = entry.command; + item.appendChild(command); + } + + return item; +} + +/** + * @param {string} heading + * @param {string} listTag `ol` for the ordered setup steps, `ul` for the rest. + * @param {Array<{title: string, detail: string, command?: string}>} entries + * @returns {DocumentFragment} + */ +function renderSection(heading, listTag, entries) { + const fragment = document.createDocumentFragment(); + + const title = document.createElement('h3'); + title.className = 'guide-heading'; + title.textContent = heading; + fragment.appendChild(title); + + const list = document.createElement(listTag); + list.className = 'guide-list'; + for (const entry of entries) list.appendChild(renderEntry('li', entry)); + fragment.appendChild(list); + + return fragment; +} + +/** + * Build the guide card. + * + * @param {() => void} onDismiss Called when the reader closes it. + * @returns {HTMLElement} + */ +export function renderGuide(onDismiss) { + const wrapper = document.createElement('section'); + wrapper.className = 'guide'; + wrapper.setAttribute('aria-label', 'Setup guide'); + + const bar = document.createElement('div'); + bar.className = 'guide-bar'; + + const title = document.createElement('h2'); + title.className = 'guide-title'; + title.textContent = 'Setting up, and what to expect'; + bar.appendChild(title); + + const close = document.createElement('button'); + close.className = 'chip-remove'; + close.type = 'button'; + close.textContent = '×'; + close.setAttribute('aria-label', 'Close the guide'); + close.addEventListener('click', () => onDismiss()); + bar.appendChild(close); + + wrapper.appendChild(bar); + + const blurb = document.createElement('p'); + blurb.className = 'guide-blurb'; + blurb.textContent = + 'Everything runs on your machine. No account, no internet after setup, and nothing you type or open leaves this computer.'; + wrapper.appendChild(blurb); + + wrapper.appendChild(renderSection('Setup — four steps', 'ol', SETUP)); + wrapper.appendChild(renderSection('What to expect', 'ul', EXPECT)); + + return wrapper; +} + +/** Exported for the tests, which assert the guide covers each of these. */ +export const sections = { SETUP, EXPECT }; diff --git a/app/webview/index.html b/app/webview/index.html index 678d645..d507a23 100644 --- a/app/webview/index.html +++ b/app/webview/index.html @@ -68,6 +68,21 @@ + + +
diff --git a/app/webview/main.js b/app/webview/main.js index 0881b7a..9bcaeae 100644 --- a/app/webview/main.js +++ b/app/webview/main.js @@ -11,6 +11,7 @@ import { createMessage, appendImages, TraceView, renderTodos, renderChanges } fr import { ThinkingIndicator } from './components/thinkingIndicator.js'; import { renderPlanChecklist } from './components/planChecklist.js'; import { renderClarification } from './components/clarificationCard.js'; +import { renderGuide } from './components/guideCard.js'; import { render } from './components/markdown.js'; const vscode = acquireVsCodeApi(); @@ -30,6 +31,7 @@ const el = { stepSessions: document.getElementById('step-sessions'), addFile: document.getElementById('add-file'), addImage: document.getElementById('add-image'), + guide: document.getElementById('guide'), status: document.getElementById('status'), sessionBadge: document.getElementById('session-badge'), }; @@ -82,6 +84,37 @@ function clearWelcome() { if (welcome) welcome.remove(); } +/* ------------------------------------------------------------------- guide */ + +/* + The guide sits at the end of the transcript and scrolls with it, so it can be read + alongside whatever prompted the reader to open it. It is removed rather than hidden: + a stale copy halfway up a long conversation reads as part of the run. +*/ +function toggleGuide() { + const open = document.getElementById('guide-card'); + if (open) { + closeGuide(); + return; + } + + clearWelcome(); + const card = renderGuide(closeGuide); + card.id = 'guide-card'; + el.messages.appendChild(card); + el.guide.setAttribute('aria-pressed', 'true'); + scrollToEnd(); +} + +function closeGuide() { + const card = document.getElementById('guide-card'); + if (card) card.remove(); + el.guide.setAttribute('aria-pressed', 'false'); + // The welcome screen is the empty state, so it comes back only if closing the guide + // has actually left the transcript empty — not on top of a conversation. + if (el.messages.children.length === 0) showWelcome(); +} + /* ------------------------------------------------------------------- chips */ /** @type {Array<{kind: 'file' | 'image', name: string, path: string, dataUri?: string}>} */ @@ -431,6 +464,7 @@ el.stepSessions.addEventListener('click', () => { vscode.postMessage({ type: 'step-sessions', enabled: state.stepSessions }); }); +el.guide.addEventListener('click', toggleGuide); el.permissions.addEventListener('click', () => vscode.postMessage({ type: 'permissions' })); el.addFile.addEventListener('click', () => vscode.postMessage({ type: 'attach-file' })); el.addImage.addEventListener('click', () => vscode.postMessage({ type: 'attach-image' })); diff --git a/app/webview/style.css b/app/webview/style.css index 32de296..045de17 100644 --- a/app/webview/style.css +++ b/app/webview/style.css @@ -771,3 +771,86 @@ select.control { font-size: var(--fs-sm); color: var(--muted); } + +/* + The setup guide. + + Bordered and inset like `.clarify`, and for the same reason: it is a block of reading + rather than a thing to act on, and the sunrise is spoken for. The one visual weight it + does carry is on the step titles, because someone scanning for "which step am I on" + should not have to read the paragraphs to find out. +*/ +.guide { + margin: var(--sp-3) 0; + padding: var(--sp-3) var(--sp-4); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--surface); +} + +.guide-bar { + display: flex; + align-items: flex-start; + gap: var(--sp-2); +} + +.guide-title { + flex: 1; + margin: 0; + font-size: 1.05em; +} + +.guide-blurb { + margin: var(--sp-2) 0 0; + font-size: var(--fs-sm); + color: var(--muted); +} + +.guide-heading { + margin: var(--sp-4) 0 0; + font-size: var(--fs-sm); + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); +} + +.guide-list { + margin: var(--sp-2) 0 0; + padding-left: var(--sp-4); + display: flex; + flex-direction: column; + gap: var(--sp-3); +} + +.guide-item { + display: flex; + flex-direction: column; + gap: 2px; +} + +.guide-item-title { + font-weight: 600; +} + +.guide-item-detail { + font-size: var(--fs-sm); + color: var(--muted); +} + +/* + A command is the one thing here the reader has to reproduce exactly, so it gets the + editor's monospace font and a box — and its own scrollbar, because the panel is often + docked narrow and a wrapped command line is a mistyped command line. +*/ +.guide-command { + align-self: flex-start; + max-width: 100%; + margin-top: var(--sp-1); + padding: var(--sp-1) var(--sp-2); + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--vscode-textCodeBlock-background, rgba(128, 128, 128, 0.12)); + font-size: var(--fs-sm); + white-space: pre; + overflow-x: auto; +} diff --git a/test/unit/webviewComponents.test.js b/test/unit/webviewComponents.test.js index 3d8f0d0..ec6b311 100644 --- a/test/unit/webviewComponents.test.js +++ b/test/unit/webviewComponents.test.js @@ -117,7 +117,16 @@ function installStubDom() { setAttribute(name, value) { this.attributes[name] = String(value); }, - addEventListener() {}, + // Recorded rather than ignored: a button whose handler is never attached looks + // identical to a wired one in a structural assertion, and that is precisely the + // bug worth catching in a card made of buttons. + listeners: {}, + addEventListener(event, handler) { + this.listeners[event] = handler; + }, + click() { + if (this.listeners.click) this.listeners.click(); + }, }); const previous = global.document; @@ -363,3 +372,90 @@ describe('thinking indicator lines', () => { assert.strictEqual(mod.pickLine([], 'x'), ''); }); }); + +describe('the setup guide card', () => { + /** @type {(onDismiss: () => void) => any} */ + let renderGuide; + /** @type {{SETUP: any[], EXPECT: any[]}} */ + let sections; + /** @type {() => void} */ + let restore; + + before(async () => { + // See the note above — the specifier is local and literal. + // eslint-disable-next-line no-unsanitized/method + ({ renderGuide, sections } = await import(moduleUrl('components/guideCard.js'))); + }); + + beforeEach(() => { + restore = installStubDom(); + }); + + afterEach(() => restore()); + + /** Every node in the tree carrying this class. */ + const allWithClass = (node, className) => { + const found = node.className === className ? [node] : []; + for (const child of node.children) found.push(...allWithClass(child, className)); + return found; + }; + + const firstWithClass = (node, className) => allWithClass(node, className)[0]; + + it('renders one item per documented step and expectation', () => { + const card = renderGuide(() => {}); + const items = allWithClass(card, 'guide-item'); + assert.strictEqual(items.length, sections.SETUP.length + sections.EXPECT.length); + }); + + it('gives every item both a title and the detail under it', () => { + const card = renderGuide(() => {}); + for (const item of allWithClass(card, 'guide-item')) { + assert.ok(firstWithClass(item, 'guide-item-title').textContent.length > 0); + assert.ok(firstWithClass(item, 'guide-item-detail').textContent.length > 0); + } + }); + + it('closes through the callback rather than by touching the DOM itself', () => { + // The card does not know where it was appended, so dismissal has to go back to + // whoever put it there. A close button that removed its own wrapper would leave + // the header button still reading "pressed". + let closed = 0; + const card = renderGuide(() => { + closed += 1; + }); + + const close = allWithClass(card, 'chip-remove')[0]; + assert.ok(close, 'the card has a close button'); + assert.strictEqual(close.attributes['aria-label'], 'Close the guide'); + + close.click(); + assert.strictEqual(closed, 1); + }); + + it('puts a command in a code element, not in prose', () => { + const card = renderGuide(() => {}); + const commands = allWithClass(card, 'guide-command'); + assert.ok(commands.length > 0, 'at least one step has a command to paste'); + for (const command of commands) assert.strictEqual(command.tagName, 'CODE'); + }); + + /* + Content assertions, because this card is the only place a first-time user is told + these things, and a well-meaning edit that drops one of them costs a user their + first session. Each is checked as a fact the guide states, not as exact wording. + */ + it('names what has to be installed and the one command that installs a model', () => { + const text = renderGuide(() => {}).textContent; + assert.match(text, /Ollama/); + assert.match(text, /ollama pull/); + assert.match(text, /Open Folder/i); + }); + + it('sets expectations about speed, approval, and the three modes', () => { + const text = renderGuide(() => {}).textContent; + assert.match(text, /1–5 minutes/); + assert.match(text, /approve/i); + for (const mode of ['Agent', 'Plan', 'Ask']) assert.match(text, new RegExp(mode)); + }); +}); From 400aad130d2de5fc7492bfbcdc72cc8c7473be53 Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 20 Aug 2026 14:33:50 +0800 Subject: [PATCH 2/9] test(1.0.0): a dead button is the one UI defect nothing else here can see MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asked to confirm every control in the panel is clickable and does something, the honest answer was that no test could tell. The component tests build nodes and assert their shape. The integration tests drive the host. Neither notices a button in index.html that nobody listened to, or a control posting `attach-flie` into a switch that falls through to `default` — both of which look entirely normal on screen until someone clicks. So this checks the seams as text: every interactive element is resolved in main.js, every button has a way to be activated (own listener, delegated container, or the form it submits), and the message protocol closes in both directions. Also the reverse — a handler delegating on [data-mode] when no button carries it is the same dead control seen from the other side. 52 checks, covering 13 webview-to-host message types and all 16 coming back. The extraction regexes are guarded by a count assertion, since a broken regex would otherwise make every check pass against an empty set. --- test/unit/webviewWiring.test.js | 179 ++++++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 test/unit/webviewWiring.test.js diff --git a/test/unit/webviewWiring.test.js b/test/unit/webviewWiring.test.js new file mode 100644 index 0000000..2aca879 --- /dev/null +++ b/test/unit/webviewWiring.test.js @@ -0,0 +1,179 @@ +'use strict'; + +/** + * Every control in the panel does something, and the something is handled. + * + * A dead button is the one UI defect that no other test in this repo can see. The + * component tests build nodes and assert their shape; the integration tests drive the + * host. Neither notices that `index.html` grew a button nobody listened to, or that a + * control posts `attach-flie` and the host's switch quietly falls through to + * `default`. Both have happened in webviews, and both look completely normal on screen + * until clicked. + * + * So this reads the three files as text and checks the seams between them: + * + * 1. Every interactive element in `index.html` is looked up in `main.js`. + * 2. Every one of them has a way to be activated — its own listener, a delegated + * listener on its container, or the form it submits. + * 3. Every message `main.js` posts is a case the host actually handles. + * 4. Every message the host posts has a handler in `main.js`. + * + * Static analysis is the right shape here on purpose. Loading the real `main.js` would + * need `acquireVsCodeApi`, a live DOM, and the whole panel standing up — which is the + * integration suite's job, and it still would not tell us a button was never wired. + */ + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const webviewDir = path.join(__dirname, '..', '..', 'app', 'webview'); +const read = (...parts) => fs.readFileSync(path.join(...parts), 'utf8'); + +const html = read(webviewDir, 'index.html'); +const mainJs = read(webviewDir, 'main.js'); +const chatTabJs = read(__dirname, '..', '..', 'app', 'features', 'chatTab.js'); + +const componentsJs = fs + .readdirSync(path.join(webviewDir, 'components')) + .filter((name) => name.endsWith('.js')) + .map((name) => read(webviewDir, 'components', name)) + .join('\n'); + +/** Every `