OXYGENHUB

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