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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions DECISION_JOURNAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
58 changes: 58 additions & 0 deletions prompt-launcher/src/lib/launcherReadyGate.js
Original file line number Diff line number Diff line change
@@ -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<unknown>} 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<T>} initialDataPromise
* @param {{ scheduleAfterInitialData: () => void }} gate
* @returns {Promise<T>}
*/
export async function notifyWhenInitialDataSettles(initialDataPromise, gate) {
try {
return await initialDataPromise;
} finally {
gate.scheduleAfterInitialData();
}
}
105 changes: 105 additions & 0 deletions prompt-launcher/src/lib/launcherReadyGate.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
28 changes: 24 additions & 4 deletions prompt-launcher/src/lib/tauriClient.ts
Original file line number Diff line number Diff line change
@@ -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<AppConfig>("get_config"),
listPrompts: () => invoke<PromptEntry[]>("list_prompts"),
searchPrompts: (query: string, limit: number, favoritesOnly: boolean) =>
invoke<PromptEntry[]>("search_prompts", { query, limit, favoritesOnly }),
searchPrompts: async (query: string, limit: number, favoritesOnly: boolean) => {
return notifyWhenInitialDataSettles(
invoke<PromptEntry[]>("search_prompts", {
query,
limit,
favoritesOnly
}),
launcherReadyGate
);
},
setPromptsDir: (path: string) =>
invoke<PromptEntry[]>("set_prompts_dir", { path }),
createPromptFile: (name: string) =>
Expand Down Expand Up @@ -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();
}
};
Loading