fix: keep starter prompts when switching harness or model (#284)

* fix: keep starter prompts when switching harness or model

The four starter prompts are cached per project brief and locale only, and
the UI keys them by project, so changing the composer's harness or model
no longer regenerates them or flashes the loading skeleton.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore: rebuild ui/dist

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: retry starter prompts only for a harness that failed

A failed generation is remembered per agent, not per project, so switching
from a broken harness to a working one still produces prompts. The route
answers with an empty list when there is nothing to offer, so projects with
experiments never show the loading skeleton on a switch. Rebuilds ui/dist.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Myles Anderson
2026-09-03 18:03:49 -07:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 56ed86d466
commit b66af71a5b
6 changed files with 90 additions and 93 deletions
+3 -2
View File
@@ -2057,7 +2057,8 @@ async fn project_starter_prompts(
.as_deref()
.map(str::trim)
.filter(|h| local::harness::is_chat_harness(h));
// A project with experiments is past "getting started"; skip the model call.
// Past "getting started" or no chat harness named: nothing to offer
// (empty), as opposed to a harness that could not answer (null).
let prompts = match harness {
Some(harness) if experiment_count == 0 => {
let locale = q.locale.as_deref().unwrap_or("en");
@@ -2072,7 +2073,7 @@ async fn project_starter_prompts(
};
local::starter::prompts(&project, &agent, locale).await
}
_ => None,
_ => Some(Vec::new()),
};
Ok(Json(json!({ "prompts": prompts })))
}
+29 -39
View File
@@ -76,7 +76,7 @@ pub struct Agent {
impl Agent {
/// The model as far as the harness's one-shot cares: dropped for a
/// harness that ignores it, so a picker change doesn't regenerate.
/// harness that ignores it.
fn effective_model(&self) -> Option<&str> {
let honours =
super::harness::chat_harness(&self.harness).is_some_and(|h| h.one_shot_honours_model());
@@ -136,7 +136,7 @@ pub fn prewarm(name: String, paper_id: Option<String>, path: Option<String>, loc
});
}
/// Cache by content (brief, harness, locale) so a pre-warmed entry serves the
/// Cache by content (brief, locale) so a pre-warmed entry serves the
/// project created after it. The paper text (a network fetch) is fixed for a
/// paper id, so it is fetched only on a miss and never decides staleness.
async fn generate(
@@ -145,14 +145,21 @@ async fn generate(
agent: &Agent,
locale: &str,
) -> Option<Vec<StarterPrompt>> {
let key = fingerprint(&brief, agent, locale);
let key = fingerprint(&brief, locale);
// Serialize per brief: an in-flight pre-warm, the dev double-effect, and a
// reopened empty state all wait on the first call's cache entry.
let lock = brief_lock(&key);
let _guard = lock.lock().await;
match lock_map(cache()).get(&key) {
Some(Cached::Prompts(prompts)) => return Some(prompts.clone()),
Some(Cached::Failed(at)) if at.elapsed() < FAILURE_TTL => return None,
// Only the agent that failed is held back; another may still answer.
Some(Cached::Failed { at, harness, model })
if at.elapsed() < FAILURE_TTL
&& *harness == agent.harness
&& model.as_deref() == agent.effective_model() =>
{
return None;
}
_ => {}
}
let prompts = generate_uncached(brief, paper_id, agent, locale).await;
@@ -162,7 +169,11 @@ async fn generate(
}
let entry = match &prompts {
Some(prompts) => Cached::Prompts(prompts.clone()),
None => Cached::Failed(Instant::now()),
None => Cached::Failed {
at: Instant::now(),
harness: agent.harness.clone(),
model: agent.effective_model().map(String::from),
},
};
cache.insert(key, entry);
prompts
@@ -246,7 +257,11 @@ const MAX_CACHED: usize = 64;
enum Cached {
Prompts(Vec<StarterPrompt>),
Failed(Instant),
Failed {
at: Instant,
harness: String,
model: Option<String>,
},
}
fn cache() -> &'static Cache {
@@ -269,12 +284,10 @@ fn brief_lock(key: &str) -> Arc<tokio::sync::Mutex<()>> {
locks.entry(key.to_string()).or_default().clone()
}
fn fingerprint(brief: &str, agent: &Agent, locale: &str) -> String {
/// The agent is left out on purpose: switching harness or model in the
/// composer keeps the prompts already on screen instead of regenerating them.
fn fingerprint(brief: &str, locale: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(agent.harness.as_bytes());
hasher.update(b"\0");
hasher.update(agent.effective_model().unwrap_or("").as_bytes());
hasher.update(b"\0");
hasher.update(locale.as_bytes());
hasher.update(b"\0");
hasher.update(brief.as_bytes());
@@ -693,33 +706,10 @@ mod tests {
}
#[test]
fn fingerprint_changes_with_brief_agent_and_locale() {
let agent = |harness: &str, model: Option<&str>| Agent {
harness: harness.into(),
model: model.map(String::from),
};
let base = fingerprint("brief", &agent("claude-code", None), "en");
assert_ne!(
base,
fingerprint("brief2", &agent("claude-code", None), "en")
);
assert_ne!(base, fingerprint("brief", &agent("codex", None), "en"));
// Claude's one-shot ignores the picked model, so neither does the key.
assert_eq!(
base,
fingerprint("brief", &agent("claude-code", Some("opus")), "en")
);
assert_ne!(
fingerprint("brief", &agent("codex", None), "en"),
fingerprint("brief", &agent("codex", Some("gpt-5.5")), "en")
);
assert_ne!(
base,
fingerprint("brief", &agent("claude-code", None), "fa")
);
assert_eq!(
base,
fingerprint("brief", &agent("claude-code", None), "en")
);
fn fingerprint_changes_with_brief_and_locale() {
let base = fingerprint("brief", "en");
assert_ne!(base, fingerprint("brief2", "en"));
assert_ne!(base, fingerprint("brief", "fa"));
assert_eq!(base, fingerprint("brief", "en"));
}
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -49,7 +49,7 @@
html { background: #ffffff; }
html[data-theme="dark"] { background: #0e0c0c; }
</style>
<script type="module" crossorigin src="/assets/index-BpcDDtWX.js"></script>
<script type="module" crossorigin src="/assets/index-2YXZFvsW.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-QObKDbL8.css">
</head>
<body>
+2 -1
View File
@@ -250,7 +250,8 @@ export interface StarterPrompt {
}
export interface ProjectStarterPrompts {
/** Null when the project already has experiments or no harness could answer. */
/** Empty when the project already has experiments; null when the harness
* could not answer. */
prompts: StarterPrompt[] | null;
}
+15 -10
View File
@@ -60,6 +60,7 @@ import {
getChatMessages,
getProjectStarterPrompts,
getSkills,
type HarnessId,
type StarterPrompt,
interruptChat,
listChatSessions,
@@ -5100,31 +5101,35 @@ export function ChatPanel({
// Opening a session or returning from settings starts pinned at the latest messages.
const threadMounted = mainView === "chat" && (messages.length > 0 || busy);
// Keyed by project+harness so a switch never shows another project's prompts.
// Keyed by project so a switch never shows another project's prompts; a
// harness or model switch keeps them, and only a failed harness is retried.
const starterHarness = composerSelection?.harness ?? null;
const starterModel = composerSelection?.model ?? null;
const [starter, setStarter] = useState<{
key: string;
projectId: string;
harness: HarnessId;
prompts: StarterPrompt[] | null;
} | null>(null);
const starterKey = `${projectId}\0${starterHarness ?? ""}\0${starterModel ?? ""}`;
const starterSettled =
starter?.projectId === projectId &&
(starter.prompts !== null || starter.harness === starterHarness);
const starterVisible = mainView === "chat" && !threadMounted && !historyLoading;
useEffect(() => {
if (!starterVisible || !starterHarness) return;
if (!starterVisible || !starterHarness || starterSettled) return;
let current = true;
getProjectStarterPrompts(projectId, starterHarness, starterModel, getLocale())
.then((result) => {
if (current) setStarter({ key: starterKey, prompts: result.prompts });
if (current) setStarter({ projectId, harness: starterHarness, prompts: result.prompts });
})
.catch(() => {
if (current) setStarter({ key: starterKey, prompts: null });
if (current) setStarter({ projectId, harness: starterHarness, prompts: null });
});
return () => {
current = false;
};
}, [projectId, starterHarness, starterModel, starterKey, starterVisible]);
const starterPrompts = starter?.key === starterKey ? starter.prompts : null;
const starterLoading = starterHarness !== null && starter?.key !== starterKey;
}, [projectId, starterHarness, starterModel, starterSettled, starterVisible]);
const starterPrompts = starterSettled && starter ? starter.prompts : null;
const starterLoading = starterHarness !== null && !starterSettled;
const applyStarterPrompt = (prompt: string) => {
setDraft(prompt);
setSkillMenuDismissed(false);
@@ -6015,7 +6020,7 @@ export function ChatPanel({
))}
</div>
)}
{starterPrompts && (
{starterPrompts && starterPrompts.length > 0 && (
<div className={STARTER_GRID_CLASS} role="group" aria-label={m.chat_panel_starter_prompts()}>
{starterPrompts.map((item, index) => {
const Icon = STARTER_ICONS[index];