fix(prompts): refresh active prompts after external file edits (#7194)

Reload the enabled prompt from its live file when the Prompts list loads, and refresh the open panel when the window regains focus, so external edits to CLAUDE.md / AGENTS.md show up without a restart.

- Keep saved templates available when the live file cannot be read.
- Skip the backfill for empty or whitespace-only files, matching enable_prompt.
- Skip the backfill while a sync/restore holds the sync lock, so a list load cannot write a stale live file into a freshly restored database.
This commit is contained in:
Allen Xu authored and GitHub committed 2026-09-20 17:13:55 +08:00
1 parent 33c806269f
commit a659440b9f
4 files changed
+180 -23

No files matched your search

+23 -1
View File
@@ -59,7 +59,29 @@ impl PromptService {
if matches!(app, AppType::Pi) {
return get_pi_prompts(state);
}
state.db.get_prompts(app.as_str())
// A restore replaces the DB before projecting it to live files. Keep
// backfill outside that window, including calls outside the UI command.
let Ok(_sync_guard) = super::sync_protocol::sync_mutex().try_lock() else {
return state.db.get_prompts(app.as_str());
};
let mut prompts = state.db.get_prompts(app.as_str())?;
// External editors change the live file without updating the saved
// selection. Refresh only that selection; inactive templates are separate.
if let Some(prompt) = prompts.values_mut().find(|prompt| prompt.enabled) {
match Self::get_current_file_content(app.clone()) {
Ok(Some(content)) if !content.trim().is_empty() && prompt.content != content => {
prompt.content = content;
prompt.updated_at = Some(get_unix_timestamp()?);
state.db.save_prompt(app.as_str(), prompt)?;
}
Ok(_) => {}
Err(error) => log::warn!(
"Failed to refresh {} prompt from live file; keeping saved prompts: {error}",
app.as_str()
),
}
}
Ok(prompts)
}
pub fn upsert_prompt(
+110
View File
@@ -0,0 +1,110 @@
mod support;
use cc_switch_lib::{AppType, Prompt, PromptService};
use std::fs;
use support::{create_test_state, ensure_test_home, reset_test_fs, test_mutex};
#[test]
fn prompt_list_refreshes_external_edits_without_changing_inactive_templates() {
let _guard = test_mutex().lock().unwrap();
reset_test_fs();
let home = ensure_test_home();
let state = create_test_state().unwrap();
for (app, relative_path) in [
(AppType::Claude, ".claude/CLAUDE.md"),
(AppType::Codex, ".codex/AGENTS.md"),
] {
let active = Prompt {
id: "active".into(),
name: "My instructions".into(),
content: "original".into(),
description: Some("Keep my metadata".into()),
enabled: true,
created_at: Some(1),
updated_at: Some(1),
};
let inactive = Prompt {
id: "inactive".into(),
enabled: false,
..active.clone()
};
state.db.save_prompt(app.as_str(), &inactive).unwrap();
PromptService::upsert_prompt(&state, app.clone(), &active.id, active.clone()).unwrap();
let path = home.join(relative_path);
for content in ["externally edited\n", "another edit\n"] {
fs::write(&path, content).unwrap();
let prompts = PromptService::get_prompts(&state, app.clone()).unwrap();
assert_eq!(prompts["active"].content, content);
assert_eq!(prompts["active"].name, active.name);
assert_eq!(prompts["active"].description, active.description);
assert_eq!(prompts["active"].created_at, active.created_at);
assert!(prompts["active"].enabled);
assert_eq!(prompts["inactive"].content, "original");
assert_eq!(fs::read_to_string(&path).unwrap(), content);
assert_eq!(
state.db.get_prompts(app.as_str()).unwrap()["active"].content,
content
);
}
let mut saved = state.db.get_prompts(app.as_str()).unwrap()["active"].clone();
saved.content = "saved instructions".into();
fs::write(&path, &saved.content).unwrap();
saved.updated_at = Some(42);
state.db.save_prompt(app.as_str(), &saved).unwrap();
assert_eq!(
PromptService::get_prompts(&state, app.clone()).unwrap()["active"].updated_at,
Some(42)
);
for content in ["", " \t\r\n"] {
fs::write(&path, content).unwrap();
let prompts = PromptService::get_prompts(&state, app.clone()).unwrap();
assert_eq!(prompts["active"].content, saved.content);
assert_eq!(prompts["active"].updated_at, Some(42));
assert_eq!(prompts["inactive"].content, "original");
assert_eq!(fs::read_to_string(&path).unwrap(), content);
assert_eq!(
state.db.get_prompts(app.as_str()).unwrap()["active"].content,
saved.content
);
}
// An editor may save UTF-16: the live read fails, but saved templates
// must remain available so the user can repair the file from the UI.
let utf16 = [0xff, 0xfe, b'A', 0];
fs::write(&path, utf16).unwrap();
let prompts = PromptService::get_prompts(&state, app.clone()).unwrap();
assert_eq!(prompts["active"].content, saved.content);
assert_eq!(prompts["active"].updated_at, Some(42));
assert_eq!(prompts["inactive"].content, "original");
assert_eq!(fs::read(&path).unwrap(), utf16);
assert_eq!(
state.db.get_prompts(app.as_str()).unwrap()["active"].updated_at,
Some(42)
);
assert_eq!(
state.db.get_prompts(app.as_str()).unwrap()["active"].content,
saved.content
);
assert!(PromptService::get_current_file_content(app.clone()).is_err());
fs::remove_file(&path).unwrap();
assert_eq!(
PromptService::get_prompts(&state, app.clone()).unwrap()["active"].updated_at,
Some(42)
);
assert!(!path.exists());
saved.enabled = false;
state.db.save_prompt(app.as_str(), &saved).unwrap();
fs::write(&path, "unmanaged instructions").unwrap();
assert_eq!(
PromptService::get_prompts(&state, app).unwrap()["active"].content,
saved.content
);
assert_eq!(fs::read_to_string(path).unwrap(), "unmanaged instructions");
}
}
+7
View File
@@ -132,6 +132,13 @@ const StandardPromptPanel = React.forwardRef<
if (open) void runExternalReload();
}, [appId, open, runExternalReload]);
useEffect(() => {
if (!open) return;
const handleFocus = () => void runExternalReload();
window.addEventListener("focus", handleFocus);
return () => window.removeEventListener("focus", handleFocus);
}, [open, runExternalReload]);
useEffect(() => {
setSearchQuery("");
overlayOpenRef.current = false;
+40 -22
View File
@@ -475,36 +475,54 @@ describe("PromptPanel", () => {
expect(onNavigationBlockedChange).toHaveBeenLastCalledWith(false);
});
it("queues external reloads while an edit or confirmation is open", async () => {
renderPanel();
it("refreshes on window focus and removes the listener on unmount", async () => {
const { unmount } = renderPanel();
await waitForPanelReady();
mocks.reload.mockClear();
fireEvent.click(screen.getAllByTitle("common.edit")[0]);
act(() => {
window.dispatchEvent(
new CustomEvent("prompt-imported", { detail: { app: "claude" } }),
);
});
expect(mocks.reload).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "form-close" }));
fireEvent(window, new Event("focus"));
await waitFor(() => expect(mocks.reload).toHaveBeenCalledTimes(1));
mocks.reload.mockClear();
await waitForPanelReady();
fireEvent.click(screen.getAllByTitle("common.delete")[0]);
act(() => {
window.dispatchEvent(
new CustomEvent("prompt-imported", { detail: { app: "claude" } }),
);
});
unmount();
mocks.reload.mockClear();
fireEvent(window, new Event("focus"));
expect(mocks.reload).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "cancel-dialog" }));
await waitFor(() => expect(mocks.reload).toHaveBeenCalledTimes(1));
});
it.each(["focus", "prompt-imported"])(
"queues %s reloads while an edit or confirmation is open",
async (trigger) => {
renderPanel();
await waitForPanelReady();
mocks.reload.mockClear();
fireEvent.click(screen.getAllByTitle("common.edit")[0]);
fireEvent(
window,
trigger === "focus"
? new Event("focus")
: new CustomEvent("prompt-imported", { detail: { app: "claude" } }),
);
expect(mocks.reload).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "form-close" }));
await waitFor(() => expect(mocks.reload).toHaveBeenCalledTimes(1));
mocks.reload.mockClear();
await waitForPanelReady();
fireEvent.click(screen.getAllByTitle("common.delete")[0]);
act(() => {
window.dispatchEvent(
new CustomEvent("prompt-imported", { detail: { app: "claude" } }),
);
});
expect(mocks.reload).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "cancel-dialog" }));
await waitFor(() => expect(mocks.reload).toHaveBeenCalledTimes(1));
},
);
it("starts the latest app reload without waiting for an older app", async () => {
let resolveClaudeReload!: () => void;
const claudeReload = vi.fn(