diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md index af06869..ea6d598 100644 --- a/docs/VERIFICATION.md +++ b/docs/VERIFICATION.md @@ -248,7 +248,7 @@ proves recall degrades to recency when embeddings are unavailable. `npm run veri narration containing that value. `npm run verify:packaged-natural-memory-turn` also keeps live Ollama enabled, teaches a stated preference through packaged `turnRun`, waits for the extracted profile fact, relaunches, asks about it through a later turn, and asserts -the floating Ask shows NO success banner after recall (the receipt state is empty) while a memory-status beat proves the +the floating Ask shows a brief `done` nod after recall (the receipt state reads `done`) while a memory-status beat proves the recall used memory. The natural-language packaged smoke uses the floating Ask smoke hook to inspect receipt state, but it does not launch the packaged app in floating-window mode or prove the failure receipt is visibly rendered; use `npm run verify:floating-live-turn` for the visible floating Ask path. @@ -408,8 +408,9 @@ It is opt-in (needs a display + a vite build) and not in CI. Checks: `#floating- `collapsed`; pill reads "Ask Roro…"; `#floating-stop` exists and is not `armed`; clicking the pill → `expanded` with the input actually visible; the memory profile bridge responds before teardown; Escape → `collapsed`; smoke submit → `tasked` with trimmed pill copy and visible Stop before `run.started`; -pre-run Stop calls the no-id cancel path and shows neutral `Stopped.` copy; universal `runEnd` -collapses answer/clarify turns with no `run.started` and leaves NO receipt on success (the surface stays clean); `run.started` keeps Stop visibly armed and +pre-run Stop calls the no-id cancel path and shows neutral `Stopped.` copy; a tasked turn shows a live +`thinking…` progress line; universal `runEnd` collapses answer/clarify turns with no `run.started` and shows a +brief `done` nod that fades; `run.started` keeps Stop visibly armed and targets the captured run id; real `run.failed` disarms Stop, shows actionable error copy, hides raw spawn text, and the error remains visible after `runEnd` collapse until the next summon clears it. @@ -417,7 +418,7 @@ spawn text, and the error remains visible after `runEnd` collapse until the next floating mode, leaves `RORO_FLOATING_SMOKE` and `RORO_DEBUG_BRIDGE` off, drives the visible Ask form, and verifies real `window.companion.turnRun` answer and executor turns over the public push stream. The answer turn must emit `ActionEvent`s, reach `runEnd`, avoid the coding executor, and collapse the -floating Ask with NO success receipt (a completed turn shows no banner; memory use is proven by the status beat, not a receipt). In the +floating Ask with a brief `done` nod (an answer turn changes no files; memory use is proven by the status beat). In the deterministic fake-Ollama path, the answer narration must include the requested phrase exactly enough to prove the public event stream carried the model answer. In the optional real-Ollama path, the narration check accepts a substantive non-placeholder answer because that mode validates the local @@ -429,8 +430,8 @@ never complete/write the aborted file, collapse from `runEnd`, and show neutral following executor success turn must launch Codex with the expected `exec --json --skip-git-repo-check -s workspace-write -C ` shape, keep Stop armed for the accepted/running turn, emit a completed `file_change` and `run.completed`, write inside the selected -test project (disposable by default), disarm Stop, collapse from `runEnd`, and show NO success receipt -(the surface stays clean on success). By default it uses tiny +test project (disposable by default), disarm Stop, collapse from `runEnd`, and show a brief `done · N files` +nod that fades. By default it uses tiny local fake Ollama and Codex servers/binaries so the product-loop proof is deterministic; set `RORO_FLOATING_LIVE_USE_REAL_OLLAMA=1` when you specifically want to validate the local model path. Ignored-`SIGTERM` slot retention remains covered by the unit gate above, not this live smoke. Keep it out @@ -446,10 +447,10 @@ Start the app (`ollama serve` first if you want a real turn): `npm start`. | 2 | Click the pill (or ⌘⇧Space) | Input expands and is focused; pill chrome hidden | | 3 | Press Enter on an **empty** input | Nothing happens — no thinking flash, stays expanded | | 4 | Type "add a logout route" + Enter | After readiness accepts the turn, the cat snaps to *thinking*, the pill shows `tasked: add a logout route`, and Stop appears before any executor event | -| 5 | While a `run_agent` turn runs | `#floating-stop` remains visible with the `armed` class | +| 5 | While a `run_agent` turn runs | `#floating-stop` stays visible with the `armed` class; the status line reads a calm `working…` the whole time (never blank) | | 6 | Click Stop | Run cancels; Stop shows `Stopping...`, then disarms/hides; Ask collapses and shows neutral `Stopped.` copy | -| 7 | An answer/clarify turn (no executor run) | Ask still collapses when the turn ends (universal `runEnd`); no success banner is shown (the cat's animation conveys "done") | -| 8 | A successful executor turn completes | Ask collapses and Stop disarms; no success banner is shown. Only a failure (sticky red) or a Stop (neutral `Stopped.`) leaves a receipt | +| 7 | An answer/clarify turn (no executor run) | Ask collapses when the turn ends (universal `runEnd`) and shows a brief `done` nod that fades after a few seconds | +| 8 | A successful executor turn completes | Ask collapses and Stop disarms; a brief `done · N files` nod appears, then fades. Only a failure (sticky red) lingers | | 9 | Press Esc while expanded | Collapses back to the "Ask Roro…" pill | A CSS regression shows up as: a state that doesn't visually change (e.g. input stays hidden when diff --git a/scripts/smoke-floating-ask.mjs b/scripts/smoke-floating-ask.mjs index ccd6274..30edd2d 100644 --- a/scripts/smoke-floating-ask.mjs +++ b/scripts/smoke-floating-ask.mjs @@ -208,11 +208,12 @@ try { await evalJs(`window.__roroFloatingAskSmoke.startTask('answer without an executor')`); await sleep(100); check('#floating-stop arms for accepted answer/clarify turn', await evalJs(`document.getElementById('floating-stop').classList.contains('armed')`)); + check('#floating-error shows a live "thinking…" progress line while tasked', await evalJs(`document.getElementById('floating-error').textContent === 'thinking…' && document.getElementById('floating-error').classList.contains('progress') && !document.getElementById('floating-error').hidden`)); await evalJs(`window.__roroFloatingAskSmoke.runEnd()`); await sleep(100); check('#floating-ask collapses on successful runEnd without run.started', await evalJs(`document.getElementById('floating-ask').classList.contains('collapsed')`)); check('#floating-stop hides after successful runEnd without run.started', !(await isVisible('#floating-stop'))); - check('#floating-error stays HIDDEN after successful runEnd (success shows no banner)', await evalJs(`document.getElementById('floating-error').hidden === true && !document.getElementById('floating-error').classList.contains('success')`)); + check('#floating-error shows a brief "done" nod after successful runEnd', await evalJs(`document.getElementById('floating-error').textContent === 'done' && document.getElementById('floating-error').classList.contains('success') && !document.getElementById('floating-error').hidden`)); console.log('[smoke] asserting executor Stop targets run id after run.started…'); await evalJs(`document.getElementById('ask-pill').click()`); diff --git a/scripts/smoke-floating-live-turn.mjs b/scripts/smoke-floating-live-turn.mjs index 042e01c..16b211a 100644 --- a/scripts/smoke-floating-live-turn.mjs +++ b/scripts/smoke-floating-live-turn.mjs @@ -1007,9 +1007,8 @@ try { const el = document.getElementById('floating-error'); return { hidden: el?.hidden, text: el?.textContent ?? '', success: el?.classList.contains('success') ?? false }; })()`, {}, 'answer receipt check'); - check('floating receipt stays HIDDEN for successful answer turn (no success banner)', answerReceipt.hidden === true, JSON.stringify(answerReceipt)); - check('floating receipt has no success tone for answer turn', answerReceipt.success === false, JSON.stringify(answerReceipt)); - check('floating receipt shows no text for successful answer turn', answerReceipt.text === '', JSON.stringify(answerReceipt)); + check('floating Ask shows a brief "done" nod for successful answer turn', answerReceipt.hidden === false && answerReceipt.success === true, JSON.stringify(answerReceipt)); + check('floating answer nod reads "done" (an answer turn changes no files)', answerReceipt.text === 'done', JSON.stringify(answerReceipt)); if (!USE_REAL_OLLAMA) { const stoppedTranscript = `${STOP_TRANSCRIPT}. Start a coding task that should be stopped before the executor starts.`; @@ -1310,9 +1309,8 @@ try { const el = document.getElementById('floating-error'); return { hidden: el?.hidden, text: el?.textContent ?? '', success: el?.classList.contains('success') ?? false }; })()`, {}, 'executor receipt check'); - check('floating receipt stays HIDDEN for successful executor turn (no success banner)', executorReceipt.hidden === true, JSON.stringify(executorReceipt)); - check('floating receipt has no success tone for executor turn', executorReceipt.success === false, JSON.stringify(executorReceipt)); - check('floating receipt shows no text for successful executor turn', executorReceipt.text === '', JSON.stringify(executorReceipt)); + check('floating Ask shows a "done" nod for successful executor turn', executorReceipt.hidden === false && executorReceipt.success === true, JSON.stringify(executorReceipt)); + check('floating executor nod reports the changed-file count', /^done · \d+ files?$/.test(executorReceipt.text), JSON.stringify(executorReceipt)); const codexInvocations = await readFile(fakeCodexArgsFile, 'utf8') .then((text) => JSON.parse(text)) .catch(() => []); diff --git a/scripts/smoke-packaged-memory.mjs b/scripts/smoke-packaged-memory.mjs index e38e620..bfa3e61 100644 --- a/scripts/smoke-packaged-memory.mjs +++ b/scripts/smoke-packaged-memory.mjs @@ -1028,8 +1028,8 @@ try { check('natural-language recall turn completed with runEnd', natural?.ok === true && Boolean(natural.runEnd), natural?.message); check('natural-language recall runEnd matches turnRun result', natural?.runEnd?.runId === natural?.turnResult?.runId); check( - 'natural-language floating Ask shows NO success banner after recall (memory use proven by the status beat below)', - natural?.receiptText === '', + 'natural-language floating Ask shows a brief "done" nod after recall (memory use proven by the status beat below)', + natural?.receiptText === 'done', natural?.receiptText || JSON.stringify(natural?.floatingState ?? {}), ); check('natural-language recall emitted a memory status beat', Boolean(memoryStatus), JSON.stringify(naturalEvents)); diff --git a/src/index.css b/src/index.css index 05550d0..bd499cd 100644 --- a/src/index.css +++ b/src/index.css @@ -393,6 +393,13 @@ body.floating-window #floating-error.success { background: rgba(18, 78, 48, 0.9); border-color: rgba(126, 230, 164, 0.5); } +/* Live "roro is working" status — calm slate, clearly neither success (green) nor failure (red). */ +body.floating-window #floating-error.progress { + color: #eef2fb; + background: rgba(28, 36, 56, 0.86); + border-color: rgba(150, 175, 230, 0.4); + opacity: 0.92; +} body.floating-window #floating-stop.armed + #floating-error:not([hidden]) { bottom: 88px; } diff --git a/src/renderer/ask/floatingAsk.test.ts b/src/renderer/ask/floatingAsk.test.ts index 505cb21..314b7db 100644 --- a/src/renderer/ask/floatingAsk.test.ts +++ b/src/renderer/ask/floatingAsk.test.ts @@ -179,43 +179,40 @@ describe('floatingAsk shell (jsdom)', () => { expect(h.stop.classList.contains('armed')).toBe(false); }); - it('shows NO receipt after a successful answer turn (the cat conveys "done", not a banner)', async () => { + it('shows "thinking…" while planning, then a brief "done" nod after a successful answer turn', async () => { h.pill.click(); h.input.value = 'what did we decide?'; submit(h.form); await flush(); + expect(h.error.classList.contains('progress')).toBe(true); // live "I'm on it" while it works + expect(h.error.textContent).toBe('thinking…'); h.fireAction(memoryUsed); h.fireRunEnd(); expect(h.form.classList.contains('collapsed')).toBe(true); - // Success leaves the surface clean — no lingering "Done." banner over the user's screen. - expect(h.error.hidden).toBe(true); - expect(h.error.classList.contains('success')).toBe(false); - expect(h.error.textContent).toBe(''); + // A nod (not a lingering banner) — an answer turn changes no files. + expect(h.error.hidden).toBe(false); + expect(h.error.classList.contains('success')).toBe(true); + expect(h.error.classList.contains('progress')).toBe(false); + expect(h.error.textContent).toBe('done'); }); - it('shows NO receipt after a successful executor turn (even with changed files + memory)', async () => { + it('shows a persistent "working…" line through a multi-event task (never a frozen gap)', async () => { h.pill.click(); - h.input.value = 'edit it'; + h.input.value = 'build it'; submit(h.form); await flush(); + expect(h.error.textContent).toBe('thinking…'); h.fireAction(started); + expect(h.error.textContent).toBe('working…'); // run.started → sustained "working" feedback + // mid-task events keep the working line visible — the bug was it going blank for 30–90s. + h.fireAction({ kind: 'file_change', runId: 'r1', itemId: 'f1', status: 'completed', files: [{ path: 'a.ts', op: 'update' }], ts: 2 }); h.fireAction(memoryUsed); - h.fireAction({ - kind: 'file_change', - runId: 'r1', - itemId: 'file-1', - status: 'completed', - files: [{ path: 'src/app.ts', op: 'update' }], - ts: 2, - }); - h.fireAction({ kind: 'run.completed', runId: 'r1', ok: true, finalText: 'done', ts: 3 }); - h.fireRunEnd(); - expect(h.error.hidden).toBe(true); - expect(h.error.classList.contains('success')).toBe(false); - expect(h.error.textContent).toBe(''); + expect(h.error.hidden).toBe(false); + expect(h.error.classList.contains('progress')).toBe(true); + expect(h.error.textContent).toBe('working…'); }); - it('leaves no lingering receipt across two consecutive successful turns', async () => { + it('shows "done · 1 file" after a successful executor turn', async () => { h.pill.click(); h.input.value = 'edit it'; submit(h.form); @@ -231,20 +228,31 @@ describe('floatingAsk shell (jsdom)', () => { ts: 2, }); h.fireAction({ kind: 'run.completed', runId: 'r1', ok: true, finalText: 'done', ts: 3 }); - h.fireRunEnd('r1'); - expect(h.error.hidden).toBe(true); // first success: clean surface - - h.pill.click(); - h.input.value = 'what now?'; - h.turnRun.mockResolvedValueOnce({ runId: 'r2' }); - submit(h.form); - await flush(); - h.fireRunEnd('r2'); + h.fireRunEnd(); + expect(h.error.hidden).toBe(false); + expect(h.error.classList.contains('success')).toBe(true); + expect(h.error.textContent).toBe('done · 1 file'); + }); - // Second success also leaves nothing — no banner from either turn lingers over the screen. - expect(h.error.hidden).toBe(true); - expect(h.error.classList.contains('success')).toBe(false); - expect(h.error.textContent).toBe(''); + it('fades the "done" nod after a few seconds (a nod, not a lingering banner)', async () => { + vi.useFakeTimers(); + try { + h.pill.click(); + h.input.value = 'edit it'; + submit(h.form); + await vi.advanceTimersByTimeAsync(5); // resolve the async submit (turnRun mock + flush) + h.fireAction(started); + h.fireAction({ kind: 'file_change', runId: 'r1', itemId: 'file-1', status: 'completed', files: [{ path: 'src/app.ts', op: 'update' }], ts: 2 }); + h.fireAction({ kind: 'run.completed', runId: 'r1', ok: true, finalText: 'done', ts: 3 }); + h.fireRunEnd('r1'); + expect(h.error.textContent).toBe('done · 1 file'); // nod shown… + expect(h.error.hidden).toBe(false); + await vi.advanceTimersByTimeAsync(3500); // past DONE_NOD_MS + expect(h.error.hidden).toBe(true); // …then it fades on its own + expect(h.error.textContent).toBe(''); + } finally { + vi.useRealTimers(); + } }); it('ignores unrelated floating events and runEnd signals once the accepted run id is known', async () => { @@ -262,7 +270,9 @@ describe('floatingAsk shell (jsdom)', () => { h.fireRunEnd('r1'); expect(h.form.classList.contains('collapsed')).toBe(true); - expect(h.error.hidden).toBe(true); // success → no banner; the unrelated 'other-run' status never surfaced + // Success nod for the real run; the unrelated 'other-run' run never drove the surface. + expect(h.error.classList.contains('success')).toBe(true); + expect(h.error.textContent).toBe('done'); // no file_change for r1 → plain 'done' }); it('keeps actionable failure copy visible after runEnd collapses the Ask', async () => { diff --git a/src/renderer/ask/floatingAsk.ts b/src/renderer/ask/floatingAsk.ts index f0fa5f5..ff1908d 100644 --- a/src/renderer/ask/floatingAsk.ts +++ b/src/renderer/ask/floatingAsk.ts @@ -82,10 +82,23 @@ export function mountFloatingAsk(opts: { let receiptState: TurnReceiptState = initialTurnReceiptState(); const smokeCancelRequests: Array = []; + // The single floating status line (#floating-error) reflects roro's CURRENT status — quiet by default: + // working -> a calm 'thinking…' / 'working…' line shown the WHOLE time a task runs (floating mode hides + // every other chrome, so without this a 30–90s task looks frozen — "nothing is happening") + // success -> a brief 'done · N files' nod that auto-fades (a nod, never a lingering banner — restraint) + // stopped -> neutral 'Stopped.' | failure -> sticky red until the next summon (fail-loud) + const DONE_NOD_MS = 3200; // how long the 'done' nod lingers before it fades + let fadeTimer: ReturnType | null = null; + function clearFadeTimer(): void { + if (fadeTimer !== null) { clearTimeout(fadeTimer); fadeTimer = null; } + } + function showNotice(message: string, tone: ReceiptTone): void { + clearFadeTimer(); error.textContent = message; error.classList.toggle('neutral', tone === 'neutral'); error.classList.toggle('success', tone === 'success'); + error.classList.remove('progress'); error.hidden = false; } @@ -93,10 +106,29 @@ export function mountFloatingAsk(opts: { showNotice(message, 'error'); } + // Live "roro is working" status while a task runs. Persistent (no fade) until the next beat / done / failure. + function showProgress(label: string): void { + clearFadeTimer(); + error.textContent = label; + error.classList.remove('neutral', 'success'); + error.classList.add('progress'); + error.hidden = false; + } + + // A completed turn gets a brief nod (the cat's animation does the rest), then it fades — not a banner. + function showDoneNod(label: string): void { + clearFadeTimer(); + error.textContent = label; + error.classList.remove('neutral', 'progress'); + error.classList.add('success'); + error.hidden = false; + fadeTimer = setTimeout(clearFailure, DONE_NOD_MS); + } + function clearFailure(): void { + clearFadeTimer(); error.textContent = ''; - error.classList.remove('neutral'); - error.classList.remove('success'); + error.classList.remove('neutral', 'success', 'progress'); error.hidden = true; } @@ -164,7 +196,7 @@ export function mountFloatingAsk(opts: { receiptRunId = null; receiptCancelRequested = false; receiptState = initialTurnReceiptState(); - clearFailure(); + showProgress('thinking…'); // roro accepted the task and is planning — keep the user informed stopRequested = false; pill.textContent = `tasked: ${eff.text}`; break; @@ -244,7 +276,7 @@ export function mountFloatingAsk(opts: { receiptRunId = null; receiptCancelRequested = false; receiptState = initialTurnReceiptState(); - clearFailure(); + showProgress('thinking…'); stopRequested = false; acceptedRunId = null; pill.textContent = `tasked: ${eff.text}`; @@ -299,7 +331,7 @@ export function mountFloatingAsk(opts: { run = reduceRun(run, e); // run.started -> running+armed+runId; completed/failed -> disarm if (e.kind === 'run.started') { acceptedRunId = e.runId; - clearFailure(); + showProgress('working…'); // the coding agent is running — sustained "I'm on it" while it works dispatch({ type: 'runStarted' }); } else { if (e.kind === 'run.completed') { @@ -332,11 +364,14 @@ export function mountFloatingAsk(opts: { receiptCancelRequested = false; activeTurnSerial = 0; dispatch({ type: 'runEnded' }); - // A completed turn is conveyed by the cat's own animation — roro does NOT leave a lingering "Done." - // banner over the user's screen (restraint / never-needy). Only failures (sticky, fail-loud) and a - // 'Stopped' acknowledgement (neutral) warrant a receipt; on success we clear the surface instead. - if (receipt.tone === 'success') clearFailure(); - else showNotice(receipt.text, receipt.tone); + // Success → a brief 'done · N files' nod that fades (a nod, not a lingering banner). Stopped → neutral. + // Failure → sticky (fail-loud). The during-task 'working…' line already told the user roro was busy. + if (receipt.tone === 'success') { + const n = receiptState.changedFiles.size; + showDoneNod(n > 0 ? `done · ${n} file${n === 1 ? '' : 's'}` : 'done'); + } else { + showNotice(receipt.text, receipt.tone); + } receiptState = initialTurnReceiptState(); }; if (companion?.onActionEvent) {