diff --git a/DECISION_JOURNAL.md b/DECISION_JOURNAL.md index 355ee91..47b17c6 100644 --- a/DECISION_JOURNAL.md +++ b/DECISION_JOURNAL.md @@ -8,6 +8,12 @@ ## Log +### 2026-06-10 19:21 UTC +- CodeXWeb claimed PR #43 review follow-up comment `4670504519` with claim comment `4673644135`. +- Scope: keep the PR's mounted + initial data settled design, but make the initial prompt search failure path also release the hidden launcher window so users can see the existing error state. +- Validation passed in remote sandbox: `npm ci --include=dev --no-audit --no-fund`, `npm run test:unit` (15 passed), `npm run check` (0 errors, 0 warnings), `npm run build`, and `git diff --check`. +- Additional validation attempt: `npm run tauri build` was blocked by the remote sandbox lacking `cargo` (`cargo metadata` not found); no Tauri/Rust source was changed in this follow-up. + ### 2026-01-22 21:49 - Checked required project guidance files in this worktree: - CLAUDE.md: missing diff --git a/prompt-launcher/src/lib/launcherReadyGate.js b/prompt-launcher/src/lib/launcherReadyGate.js new file mode 100644 index 0000000..42b75b6 --- /dev/null +++ b/prompt-launcher/src/lib/launcherReadyGate.js @@ -0,0 +1,58 @@ +/** + * Creates a small gate that keeps the backend window hidden until the launcher + * has mounted and the initial prompt search has had a chance to update UI state. + * + * @param {() => Promise} notifyBackendReady + * @param {(callback: () => void) => void} [schedule] + * @param {(error: unknown) => void} [onError] + */ +export function createLauncherReadyGate( + notifyBackendReady, + schedule = (callback) => setTimeout(callback, 0), + onError = () => {} +) { + let mounted = false; + let scheduled = false; + let notified = false; + + return { + markMounted() { + mounted = true; + }, + + scheduleAfterInitialData() { + if (!mounted || scheduled || notified) { + return; + } + + scheduled = true; + schedule(() => { + void notifyBackendReady() + .then(() => { + notified = true; + }) + .catch((error) => { + scheduled = false; + onError(error); + }); + }); + } + }; +} + +/** + * Treat the first prompt search as settled whether it succeeds or fails so the + * launcher can show either results or the existing error state. + * + * @template T + * @param {Promise} initialDataPromise + * @param {{ scheduleAfterInitialData: () => void }} gate + * @returns {Promise} + */ +export async function notifyWhenInitialDataSettles(initialDataPromise, gate) { + try { + return await initialDataPromise; + } finally { + gate.scheduleAfterInitialData(); + } +} diff --git a/prompt-launcher/src/lib/launcherReadyGate.test.js b/prompt-launcher/src/lib/launcherReadyGate.test.js new file mode 100644 index 0000000..377460c --- /dev/null +++ b/prompt-launcher/src/lib/launcherReadyGate.test.js @@ -0,0 +1,105 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + createLauncherReadyGate, + notifyWhenInitialDataSettles +} from "./launcherReadyGate.js"; + +const flushAsyncHandlers = () => new Promise((resolve) => setImmediate(resolve)); + +test("does not notify the backend before the frontend is mounted", async () => { + /** @type {string[]} */ + const calls = []; + /** @type {Array<() => void>} */ + const scheduled = []; + const gate = createLauncherReadyGate( + async () => calls.push("frontend_ready"), + (callback) => scheduled.push(callback) + ); + + gate.scheduleAfterInitialData(); + assert.equal(scheduled.length, 0); + assert.deepEqual(calls, []); + + gate.markMounted(); + gate.scheduleAfterInitialData(); + assert.equal(scheduled.length, 1); + + scheduled[0](); + await flushAsyncHandlers(); + assert.deepEqual(calls, ["frontend_ready"]); +}); + +test("coalesces repeated ready requests into one backend notification", async () => { + /** @type {string[]} */ + const calls = []; + /** @type {Array<() => void>} */ + const scheduled = []; + const gate = createLauncherReadyGate( + async () => calls.push("frontend_ready"), + (callback) => scheduled.push(callback) + ); + + gate.markMounted(); + gate.scheduleAfterInitialData(); + gate.scheduleAfterInitialData(); + gate.scheduleAfterInitialData(); + + assert.equal(scheduled.length, 1); + scheduled[0](); + await flushAsyncHandlers(); + + gate.scheduleAfterInitialData(); + assert.equal(scheduled.length, 1); + assert.deepEqual(calls, ["frontend_ready"]); +}); + +test("allows retry when the backend notification fails", async () => { + /** @type {unknown[]} */ + const errors = []; + /** @type {Array<() => void>} */ + const scheduled = []; + let attempts = 0; + const gate = createLauncherReadyGate( + async () => { + attempts += 1; + if (attempts === 1) { + throw new Error("temporary failure"); + } + }, + (callback) => scheduled.push(callback), + (error) => errors.push(error) + ); + + gate.markMounted(); + gate.scheduleAfterInitialData(); + scheduled[0](); + await flushAsyncHandlers(); + + assert.equal(attempts, 1); + assert.equal(errors.length, 1); + + gate.scheduleAfterInitialData(); + assert.equal(scheduled.length, 2); + scheduled[1](); + await flushAsyncHandlers(); + + assert.equal(attempts, 2); + assert.equal(errors.length, 1); +}); + +test("schedules readiness after initial data loading fails", async () => { + const searchError = new Error("search_prompts failed"); + let scheduled = false; + + await assert.rejects( + notifyWhenInitialDataSettles(Promise.reject(searchError), { + scheduleAfterInitialData: () => { + scheduled = true; + } + }), + searchError + ); + + assert.equal(scheduled, true); +}); diff --git a/prompt-launcher/src/lib/tauriClient.ts b/prompt-launcher/src/lib/tauriClient.ts index b976946..23d62ac 100644 --- a/prompt-launcher/src/lib/tauriClient.ts +++ b/prompt-launcher/src/lib/tauriClient.ts @@ -1,11 +1,29 @@ import { invoke } from "@tauri-apps/api/core"; +import { + createLauncherReadyGate, + notifyWhenInitialDataSettles +} from "./launcherReadyGate.js"; import type { AppConfig, PromptEntry, RecentState } from "./types"; +const launcherReadyGate = createLauncherReadyGate( + () => invoke("frontend_ready"), + (callback) => setTimeout(callback, 0), + (error) => console.warn("[frontend_ready] Failed to notify backend", error) +); + export const tauriClient = { getConfig: () => invoke("get_config"), listPrompts: () => invoke("list_prompts"), - searchPrompts: (query: string, limit: number, favoritesOnly: boolean) => - invoke("search_prompts", { query, limit, favoritesOnly }), + searchPrompts: async (query: string, limit: number, favoritesOnly: boolean) => { + return notifyWhenInitialDataSettles( + invoke("search_prompts", { + query, + limit, + favoritesOnly + }), + launcherReadyGate + ); + }, setPromptsDir: (path: string) => invoke("set_prompts_dir", { path }), createPromptFile: (name: string) => @@ -38,5 +56,7 @@ export const tauriClient = { captureActiveWindow: () => invoke("capture_active_window"), focusLastWindow: (autoPaste: boolean) => invoke("focus_last_window", { autoPaste }), - frontendReady: () => invoke("frontend_ready") -}; + frontendReady: async () => { + launcherReadyGate.markMounted(); + } +}; \ No newline at end of file