Draft with Claude proposes a title too (closes #316) c43e4f631d99
For a proposal not yet opened, the assistant begins its draft with a Title: line in the style of the changes' own titles; the hub splits it off and sends it as its own event, and the page fills the title unless the person wrote one, keeping the line out of the description. The no-script form does the same.
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 1190f964739b
- When
- · 2026-10-25 21:01 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
c43e4f631d99b9fa8a98bbb5c0e9fefc56d774fbe3fc5cd81f9f7c3b23e44865- Depends on
- 119fd17d213f 5436f0dd9429 7d2eb590051f a7e6e8e6094c acb8d109e981 b5668108487f d23464c0ac60 de505df5cf8d e71846aa07b9
- Depended on by
- fbd7c8551932
- In bookmarks
- api-unknown-fields diffusion-pin-bookmark main proposal-list-fast
- In tags
- v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 21 atoms, 0 tree operations, 5 files
Browse the tree at this change
modified CHANGELOG.md +2 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **The new proposal form lines up (#315):** as wide as the changes above it; Draft with Claude a secondary button under the editor (it fills the fields, it does not open the proposal); the draft checkbox and Open proposal on one row. |
|
| 8 | + | - **Draft with Claude proposes a title too (#316):** for a proposal not yet opened, the assistant begins its draft with a title in the style of the changes' own titles; it fills the title field unless you wrote one (the page's own suggestion does not count), and stays out of the description. |
|
| 7 | 9 | - **A waiting WASM job no longer says runners lack its image (#317):** a job with no image (the WASM tier) waited saying every runner "does not hold image" with an empty name; the explanation now skips the image check, as the claim always did. |
|
| 8 | 10 | - **A draft proposal can be made ready from `oh` and the API (#313):** `oh proposal ready N` (and `--undo` to turn it back into a draft); `PATCH …/proposals/{n}` takes `state: "open"` for a draft and `state: "draft"` for an open one, as the page's buttons do, and `draft: false|true` in the words a proposal is created with. `{"draft": false}` used to answer 200 and change nothing. |
|
| 9 | 11 | ||
| … | 317 unchanged lines |
modified assets/hub.js +8 −1
| … | 835 unchanged lines |
||
| 836 | 836 | e.preventDefault(); |
|
| 837 | 837 | function get() { return area._mde ? area._mde.value() : area.value; } |
|
| 838 | 838 | function show(t) { if (area._mde) area._mde.value(t); else area.value = t; } |
|
| 839 | + | // A new proposal's draft begins with its title line (#316): kept out |
|
| 840 | + | // of the description, and put in the title unless the person wrote one. |
|
| 841 | + | var titleField = form.querySelector('input[name="title"]'); |
|
| 842 | + | function untitled(t) { return t.replace(/^\s*title:[^\n]*(\n\s*|$)/i, ""); } |
|
| 839 | 843 | var before = get(), text = ""; |
|
| 840 | 844 | var box = (area._mde && area.parentNode.querySelector(".EasyMDEContainer")) || area; |
|
| 841 | 845 | var note = form.querySelector(".draft-note"); |
|
| … | 24 unchanged lines |
||
| 866 | 870 | if (!payload) continue; |
|
| 867 | 871 | var v; |
|
| 868 | 872 | try { v = JSON.parse(payload); } catch (_) { continue; } |
|
| 869 | - | if (kind === "part") { text += v; show(text); } |
|
| 873 | + | if (kind === "part") { text += v; show(untitled(text)); } |
|
| 874 | + | else if (kind === "title") { |
|
| 875 | + | if (titleField && (!titleField.value.trim() || titleField.value === titleField.defaultValue)) titleField.value = v; |
|
| 876 | + | } |
|
| 870 | 877 | else if (kind === "done") { show(v); end("Drafted by " + btn.value + "; nothing is saved until you save the proposal.", true); return; } |
|
| 871 | 878 | else if (kind === "error") { end(v, false); return; } |
|
| 872 | 879 | } |
|
| … | 90 unchanged lines |
modified crates/hub/src/assistants.rs +40 −5
| … | 785 unchanged lines |
||
| 786 | 786 | mod tests { |
|
| 787 | 787 | use super::*; |
|
| 788 | 788 | ||
| 789 | + | /// #316: a new proposal's draft begins with its title line, kept apart |
|
| 790 | + | /// from the description; a draft without one is all description. |
|
| 791 | + | #[test] |
|
| 792 | + | fn a_draft_s_title_line_is_split_off() { |
|
| 793 | + | assert_eq!( |
|
| 794 | + | split_title("Title: feat(flow): hand jobs back\n\n**Summary.** Runners leave."), |
|
| 795 | + | (Some("feat(flow): hand jobs back".to_string()), "**Summary.** Runners leave.".to_string()) |
|
| 796 | + | ); |
|
| 797 | + | assert_eq!(split_title(" title: \"Quoted\"\nBody"), (Some("Quoted".to_string()), "Body".to_string())); |
|
| 798 | + | assert_eq!(split_title("**Summary.** No title here."), (None, "**Summary.** No title here.".to_string())); |
|
| 799 | + | assert_eq!(split_title("Title:\nBody"), (None, "Body".to_string()), "an empty title is none"); |
|
| 800 | + | } |
|
| 801 | + | ||
| 789 | 802 | /// Anthropic's events, split anywhere across chunks, give the text, |
|
| 790 | 803 | /// leave thinking out, and say why it stopped (oxygenhub#248). |
|
| 791 | 804 | #[test] |
|
| … | 525 unchanged lines |
||
| 1317 | 1330 | ||
| 1318 | 1331 | // -------------------------------------------------------- drafting |
|
| 1319 | 1332 | ||
| 1320 | - | /// The system prompt for drafting a proposal's description. |
|
| 1321 | - | pub fn draft_system_prompt(a: &Assistant, site: &str, prompts: &str) -> String { |
|
| 1333 | + | /// The system prompt for drafting a proposal's description; `with_title`, |
|
| 1334 | + | /// for a proposal not yet opened, asks for a title line first (#316). |
|
| 1335 | + | pub fn draft_system_prompt(a: &Assistant, site: &str, prompts: &str, with_title: bool) -> String { |
|
| 1336 | + | let shape = if with_title { |
|
| 1337 | + | "Begin with one line `Title: ` and a title of at most 72 characters, in the style of the changes' own titles \ |
|
| 1338 | + | (Conventional Commits if they use them), then a blank line, then the description in Markdown: \ |
|
| 1339 | + | no greeting, no sign-off." |
|
| 1340 | + | } else { |
|
| 1341 | + | "Write Markdown for the description field only: no title line, no greeting, no sign-off." |
|
| 1342 | + | }; |
|
| 1322 | 1343 | let mut s = format!( |
|
| 1323 | 1344 | "You are {name}, an AI assistant drafting the description of a proposal on {site}, an OxygenHub site, \ |
|
| 1324 | 1345 | for the person who will open or edit it; they read and change your draft before saving, and the draft is theirs. \ |
|
| 1325 | - | Write Markdown for the description field only: no title line, no greeting, no sign-off. \ |
|
| 1346 | + | {shape} \ |
|
| 1326 | 1347 | Say what the changes do and why, what a reviewer should look at, and how to verify them; \ |
|
| 1327 | 1348 | keep to what the diff shows, and keep it short.", |
|
| 1328 | 1349 | name = a.name |
|
| … | 14 unchanged lines |
||
| 1343 | 1364 | pub target: &'a str, |
|
| 1344 | 1365 | pub changes: &'a [crate::repos::view::ChangeView], |
|
| 1345 | 1366 | pub diffs: &'a [oxy_core::repo::FileDiff], |
|
| 1367 | + | /// A proposal not yet opened: the draft begins with a title line (#316). |
|
| 1368 | + | pub with_title: bool, |
|
| 1369 | + | } |
|
| 1370 | + | ||
| 1371 | + | /// A draft's `Title: …` first line, if it has one, and the description |
|
| 1372 | + | /// after it (#316). |
|
| 1373 | + | pub fn split_title(text: &str) -> (Option<String>, String) { |
|
| 1374 | + | let text = text.trim_start(); |
|
| 1375 | + | let (first, rest) = text.split_once('\n').unwrap_or((text, "")); |
|
| 1376 | + | let Some(t) = first.trim().strip_prefix("Title:").or_else(|| first.trim().strip_prefix("title:")) else { |
|
| 1377 | + | return (None, text.trim().to_string()); |
|
| 1378 | + | }; |
|
| 1379 | + | let t: String = t.trim().trim_matches(|c| c == '"' || c == '`' || c == '*').trim().chars().take(120).collect(); |
|
| 1380 | + | ((!t.is_empty()).then_some(t), rest.trim().to_string()) |
|
| 1346 | 1381 | } |
|
| 1347 | 1382 | ||
| 1348 | 1383 | /// Ask `a` for a description, now; the answer goes into the editor, |
|
| … | 18 unchanged lines |
||
| 1367 | 1402 | d: Draft<'_>, |
|
| 1368 | 1403 | part: impl FnMut(&str), |
|
| 1369 | 1404 | ) -> Result<String, String> { |
|
| 1370 | - | let Draft { slot, title, target, changes, diffs } = d; |
|
| 1405 | + | let Draft { slot, title, target, changes, diffs, with_title } = d; |
|
| 1371 | 1406 | app.assistant_gate.lock().unwrap_or_else(|e| e.into_inner()).take(row.id, slot, &a.name)?; |
|
| 1372 | 1407 | crate::db::accounts::audit(&app.db, Some(by), "assistant.drafted", &format!("{}/{}", row.owner, row.name), &a.name).await.ok(); |
|
| 1373 | 1408 | let budget = usize::try_from(a.budget).unwrap_or(DEFAULT_BUDGET as usize).max(1_000); |
|
| 1374 | 1409 | let prompts = prompt_for_repo(&app.db, row).await.map_err(|e| e.to_string()); |
|
| 1375 | 1410 | let outcome = match prompts { |
|
| 1376 | 1411 | Ok(prompts) => { |
|
| 1377 | - | let system = draft_system_prompt(a, &app.config.site.name, &prompts); |
|
| 1412 | + | let system = draft_system_prompt(a, &app.config.site.name, &prompts, with_title); |
|
| 1378 | 1413 | ask_within(app, a, &system, budget, CALL_TIMEOUT, |room| draft_context(row, title, target, changes, diffs, room), part).await |
|
| 1379 | 1414 | } |
|
| 1380 | 1415 | Err(e) => Err(e), |
|
| … | 4 unchanged lines |
modified crates/hub/src/web/proposals.rs +44 −6
| … | 1824 unchanged lines |
||
| 1825 | 1825 | return Ok(Page(page)); |
|
| 1826 | 1826 | } |
|
| 1827 | 1827 | }; |
|
| 1828 | - | let d = crate::assistants::Draft { slot: -row.id, title: &f.title, target: &f.target, changes: &changes, diffs: &diffs }; |
|
| 1828 | + | let d = |
|
| 1829 | + | crate::assistants::Draft { slot: -row.id, title: &f.title, target: &f.target, changes: &changes, diffs: &diffs, with_title: true }; |
|
| 1829 | 1830 | match crate::assistants::draft_description(&app, &row, &me.account, &a, d).await { |
|
| 1830 | - | Ok(text) => page.body = text, |
|
| 1831 | + | Ok(text) => { |
|
| 1832 | + | let (title, body) = crate::assistants::split_title(&text); |
|
| 1833 | + | page.body = body; |
|
| 1834 | + | // The title too, unless the person wrote one (#316): the page's |
|
| 1835 | + | // own suggestion, a single change's title, counts as theirs not. |
|
| 1836 | + | let suggested = if page.preview.len() == 1 { page.preview[0].title.clone() } else { String::new() }; |
|
| 1837 | + | if let Some(t) = title.filter(|_| page.title.trim().is_empty() || page.title.trim() == suggested.trim()) { |
|
| 1838 | + | page.title = t; |
|
| 1839 | + | } |
|
| 1840 | + | } |
|
| 1831 | 1841 | Err(why) => page.error = Some(format!("{} did not answer: {why}.", a.name)), |
|
| 1832 | 1842 | } |
|
| 1833 | 1843 | Ok(Page(page)) |
|
| … | 39 unchanged lines |
||
| 1873 | 1883 | }; |
|
| 1874 | 1884 | let loaded = crate::web::proposals::load(&app, &repo, p.clone()).await.map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 1875 | 1885 | let title = if f.title.trim().is_empty() { p.title.clone() } else { f.title.clone() }; |
|
| 1876 | - | let d = crate::assistants::Draft { slot: p.id, title: &title, target: &p.target, changes: &loaded.changes, diffs: &loaded.diffs }; |
|
| 1886 | + | let d = crate::assistants::Draft { |
|
| 1887 | + | slot: p.id, |
|
| 1888 | + | title: &title, |
|
| 1889 | + | target: &p.target, |
|
| 1890 | + | changes: &loaded.changes, |
|
| 1891 | + | diffs: &loaded.diffs, |
|
| 1892 | + | with_title: false, |
|
| 1893 | + | }; |
|
| 1877 | 1894 | let draft = crate::assistants::draft_description(&app, &row, &me.account, &a, d).await; |
|
| 1878 | 1895 | match draft { |
|
| 1879 | 1896 | Ok(text) => { |
|
| … | 42 unchanged lines |
||
| 1922 | 1939 | target: String, |
|
| 1923 | 1940 | changes: Vec<ChangeView>, |
|
| 1924 | 1941 | diffs: Vec<oxy_core::repo::FileDiff>, |
|
| 1942 | + | /// Not yet opened: the draft proposes a title too (#316). |
|
| 1943 | + | with_title: bool, |
|
| 1925 | 1944 | } |
|
| 1926 | 1945 | ||
| 1927 | 1946 | /// Write the draft in a task of its own, its parts to the page. |
|
| 1928 | 1947 | fn draft_in_background(app: Shared, job: DraftJob) -> DraftEvents { |
|
| 1929 | 1948 | let (tx, rx) = tokio::sync::mpsc::channel(64); |
|
| 1930 | 1949 | tokio::spawn(async move { |
|
| 1931 | - | let DraftJob { row, by, a, slot, title, target, changes, diffs } = job; |
|
| 1932 | - | let d = crate::assistants::Draft { slot, title: &title, target: &target, changes: &changes, diffs: &diffs }; |
|
| 1950 | + | let DraftJob { row, by, a, slot, title, target, changes, diffs, with_title } = job; |
|
| 1951 | + | let d = crate::assistants::Draft { slot, title: &title, target: &target, changes: &changes, diffs: &diffs, with_title }; |
|
| 1933 | 1952 | let parts = tx.clone(); |
|
| 1934 | 1953 | let result = crate::assistants::draft_description_streaming(&app, &row, &by, &a, d, move |p: &str| { |
|
| 1935 | 1954 | // A page that went away stops nothing: the draft finishes. |
|
| … | 1 unchanged lines |
||
| 1937 | 1956 | }) |
|
| 1938 | 1957 | .await; |
|
| 1939 | 1958 | let last = match result { |
|
| 1959 | + | Ok(text) if with_title => { |
|
| 1960 | + | // The title first, for the page to put in its field (#316). |
|
| 1961 | + | let (t, body) = crate::assistants::split_title(&text); |
|
| 1962 | + | if let Some(t) = t { |
|
| 1963 | + | let _ = tx.send(draft_event("title", &t)).await; |
|
| 1964 | + | } |
|
| 1965 | + | draft_event("done", &body) |
|
| 1966 | + | } |
|
| 1940 | 1967 | Ok(text) => draft_event("done", text.trim()), |
|
| 1941 | 1968 | Err(why) => draft_event("error", &format!("{} did not answer: {why}.", a.name)), |
|
| 1942 | 1969 | }; |
|
| … | 41 unchanged lines |
||
| 1984 | 2011 | target: p.target.clone(), |
|
| 1985 | 2012 | changes: loaded.changes, |
|
| 1986 | 2013 | diffs: loaded.diffs, |
|
| 2014 | + | with_title: false, |
|
| 1987 | 2015 | }; |
|
| 1988 | 2016 | Ok(draft_in_background(app.clone(), job)) |
|
| 1989 | 2017 | } |
|
| … | 26 unchanged lines |
||
| 2016 | 2044 | if changes.is_empty() { |
|
| 2017 | 2045 | return Ok(draft_refused("Nothing to describe: the source adds nothing to the target.".into())); |
|
| 2018 | 2046 | } |
|
| 2019 | - | let job = DraftJob { slot: -row.id, row, by: me.account.clone(), a, title: f.title.clone(), target: f.target.clone(), changes, diffs }; |
|
| 2047 | + | let job = DraftJob { |
|
| 2048 | + | slot: -row.id, |
|
| 2049 | + | row, |
|
| 2050 | + | by: me.account.clone(), |
|
| 2051 | + | a, |
|
| 2052 | + | title: f.title.clone(), |
|
| 2053 | + | target: f.target.clone(), |
|
| 2054 | + | changes, |
|
| 2055 | + | diffs, |
|
| 2056 | + | with_title: true, |
|
| 2057 | + | }; |
|
| 2020 | 2058 | Ok(draft_in_background(app.clone(), job)) |
|
| 2021 | 2059 | } |
modified crates/hub/tests/proposals.rs +2 −0
| … | 448 unchanged lines |
||
| 449 | 449 | "{}", |
|
| 450 | 450 | calls[0] |
|
| 451 | 451 | ); |
|
| 452 | + | // A proposal not yet opened gets a title too (#316); the person's own is kept. |
|
| 453 | + | assert!(calls[0].contains("Begin with one line `Title: `"), "{}", calls[0]); |
|
| 452 | 454 | } |
|
| 453 | 455 | let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await.body; |
|
| 454 | 456 | assert!(page.contains("Draft with Claude"), "{page}"); |
|
| … | 667 unchanged lines |