diff --git a/compiler/Cargo.lock b/compiler/Cargo.lock index 0d301fb..1fd4e49 100644 --- a/compiler/Cargo.lock +++ b/compiler/Cargo.lock @@ -1153,7 +1153,7 @@ checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" [[package]] name = "solidjs-compiler" version = "0.0.0" -source = "git+https://github.com/solidjs/solid?rev=ac28157aba6d03b806f0043bce70a87d515e953e#ac28157aba6d03b806f0043bce70a87d515e953e" +source = "git+https://github.com/solidjs/solid?rev=5e68925cc2fd216a9fb06345019e66d5da5c523c#5e68925cc2fd216a9fb06345019e66d5da5c523c" dependencies = [ "html5ever", "htmlize", diff --git a/compiler/Cargo.toml b/compiler/Cargo.toml index 2b8a3b8..c16a697 100644 --- a/compiler/Cargo.toml +++ b/compiler/Cargo.toml @@ -26,7 +26,7 @@ regex = "1" serde_json = { version = "1", features = ["preserve_order"] } # CP-1b: Solid's compiler, at the commit ref/ runs on (ref/SOLID_NEXT), and the Oxc release it is built against (CP-1c: # the program crosses the seam, so the versions must match) -solidjs-compiler = { git = "https://github.com/solidjs/solid", rev = "ac28157aba6d03b806f0043bce70a87d515e953e", default-features = false } +solidjs-compiler = { git = "https://github.com/solidjs/solid", rev = "5e68925cc2fd216a9fb06345019e66d5da5c523c", default-features = false } oxc_allocator = "0.144" oxc_ast = "0.144" oxc_span = "0.144" diff --git a/compiler/src/check.rs b/compiler/src/check.rs index 1b13ca4..2cd3e83 100644 --- a/compiler/src/check.rs +++ b/compiler/src/check.rs @@ -291,6 +291,47 @@ impl Ctx { } } +/// R-8: the names a handler reads as snapshots (not under `pending?` or `peek`), each with whether a `try` or a `catch` +/// expression around it takes its failure; `locals`: the names the handler binds itself (parameters, `let`s). +fn snapshot_reads<'a>(n: Node<'a>, caught: bool, locals: &mut HashSet, out: &mut Vec<(String, bool)>) { + match n { + Node::E(e) => match &e.k { + ExprKind::Id(name) => { + out.push((name.clone(), caught)); + return; + } + ExprKind::Pending(_) | ExprKind::Peek(_) => return, + ExprKind::Fn { params, .. } => locals.extend(params.iter().cloned()), + ExprKind::Catch { e: inner, alt, .. } => { + snapshot_reads(Node::E(inner), true, locals, out); + snapshot_reads(Node::E(alt), caught, locals, out); + return; + } + _ => {} + }, + Node::S(st) => match &st.k { + StmtKind::Let { name, .. } => { + locals.insert(name.clone()); + } + StmtKind::Try { body, handler, fin, catch_name, .. } => { + for x in body { + snapshot_reads(Node::S(x), caught || handler.is_some(), locals, out); + } + if let Some(n) = catch_name { + locals.insert(n.clone()); + } + for x in handler.iter().chain(fin.iter()).flatten() { + snapshot_reads(Node::S(x), caught, locals, out); + } + return; + } + _ => {} + }, + } + for c in children(n, false) { + snapshot_reads(c, caught, locals, out); + } +} /// A node the reference walks generically (`Object.values`): an expression, or a statement inside one. #[derive(Clone, Copy)] pub(crate) enum Node<'a> { @@ -720,6 +761,15 @@ struct ReaderRec<'a> { env: EId, line: i64, } +/// R-8: an event handler on an element, with the element's attributes (its `disabled` covers the handler). +struct HandlerRec<'a> { + attrs: &'a [Attr], + h: &'a Expr, + env: EId, + region: RId, + line: i64, + what: String, +} struct AppRec<'a> { comp: RId, site: RId, @@ -762,6 +812,7 @@ pub struct Checker<'a> { seq: usize, uses: Vec, readers: Vec>, + handlers: Vec>, apps: Vec>, actions: Vec>, provisions: Vec>, @@ -837,6 +888,7 @@ impl<'a> Checker<'a> { seq: 0, uses: vec![], readers: vec![], + handlers: vec![], apps: vec![], actions: vec![], provisions: vec![], @@ -960,6 +1012,7 @@ impl<'a> Checker<'a> { } self.retained(); self.resolve_tree(); + self.snapshot_guards(); } fn run_later(&mut self, job: Later<'a>) { match job { @@ -2191,13 +2244,14 @@ impl<'a> Checker<'a> { } // ---- imperative scope (SC-2) ---- - /// AC-1c, AC-1d over one imperative run (`crate::infer`). + /// AC-1c, AC-1d, AC-1e over one imperative run (`crate::infer`). fn run_checks(&mut self, stmts: &[Stmt], action: bool, bound: &[String], inferred: bool) { for f in run_findings(stmts, action, bound, inferred) { let n = &f.name; match f.code { Code::HeldWrite => self.err("HELD_WRITE", "AC-1c", format!("{n} := … comes before an `await` in a body that is an action because it writes and awaits (AC-1b), so it is held until the body ends (AC-1): for state that shows while the work runs, `optimistic {n} := …` (a source with a question, ST-5c); for a reset that lands now, `onFlush(() => {{ {n} := … }})` (EF-8); to hold it on purpose, declare the action (`action name() {{ … }}`); or write it after the `await`")), Code::StaleOwnRead => self.err("STALE_OWN_READ", "AC-1d", format!("{n} is read after this change wrote it: the read sees the committed value, not the write (W-4) — keep the value you wrote in a local (`let v = …; {n} := v`) and read that, or write functionally (`{n} := v => …`, W-6)")), + Code::StaleWriteBack => self.err("STALE_WRITE_BACK", "AC-1e", format!("{n} := … after an `await` is computed from {n} as it was read before that `await`: whatever changed {n} in between (another action, a reload) is overwritten — write functionally (`{n} := v => …`, W-6), which applies to {n} as it stands at the write, or guess it (`optimistic`, AC-3) and let the answer replace the guess")), } } } @@ -2687,6 +2741,8 @@ impl<'a> Checker<'a> { self.comp(if h { "handler" } else { "reader" }, env, None, Some(format!("{} {}", tag, a.name)), |c| c.expr(&a.e, env, c2, None)); if !h { self.readers.push(ReaderRec { region, e: &a.e, env, line: self.line }); + } else { + self.handlers.push(HandlerRec { attrs, h: &a.e, env, region, line: self.line, what: format!("<{}> {}", tag, a.name) }); } } self.markup(children, env, false); @@ -2924,6 +2980,162 @@ impl<'a> Checker<'a> { } } + // ---- R-8: a handler's snapshot of a value that may be absent or failing ---- + /// R-8: every element handler's snapshot reads of an equation that can have no value yet (a first load with no `-> v`) + /// or can fail, unless handled (`-> v`; a `try` or `catch` for the failure) or covered (the element's `disabled` reads + /// it, or the element sits in a `loading` / `errored` region whose content reads it). + fn snapshot_guards(&mut self) { + let hs: Vec<(&'a [Attr], &'a Expr, EId, RId, i64, String)> = self.handlers.iter().map(|h| (h.attrs, h.h, h.env, h.region, h.line, h.what.clone())).collect(); + let mut absent_memo: HashMap = HashMap::new(); + let mut done: HashSet<*const Expr> = HashSet::new(); + for (attrs, h, env, region, line, what) in hs { + if !done.insert(h as *const Expr) { + continue; + } + let mut locals: HashSet = HashSet::new(); + let mut reads: Vec<(String, bool)> = vec![]; + snapshot_reads(Node::E(h), false, &mut locals, &mut reads); + let mut seen: HashSet = HashSet::new(); + for (n, caught) in reads { + if locals.contains(&n) { + continue; + } + let Some(s) = self.lookup(env, &n) else { continue }; + if !matches!(self.syms[s].kind, SymKind::Eq | SymKind::Source) || seen.contains(&s) { + continue; + } + let absent = self.absent(s, &mut absent_memo, &mut HashSet::new()); + let failing = !caught && self.syms[s].fails_t.is_some() && !self.sym_fails(s).cls.is_empty(); + if !absent && !failing { + continue; + } + seen.insert(s); + if attrs.iter().any(|a| a.name == "disabled" && self.depends(&a.e, env, s, &mut HashSet::new())) { + continue; + } + let need_hold = absent && !self.region_reads(region, s, |r| r.provides.contains(HOLD)); + let need_catch = failing && !self.region_reads(region, s, |r| r.catches.is_some()); + if !need_hold && !need_catch { + continue; + } + let why = match (need_hold, need_catch) { + (true, true) => "can have no value yet (a first load) and can fail", + (true, false) => "can have no value yet (a first load)", + _ => "can fail", + }; + self.line = line; + self.err( + "UNGUARDED_SNAPSHOT", + "R-8", + format!( + "{} reads {} as a snapshot, and {} {}: the click then {} — cover the element (`disabled={{pending? {}}}`, or move it inside the `loading` / `errored` region that shows {}){}", + what, + n, + n, + why, + if need_hold { "sees no value" } else { "throws its error" }, + n, + n, + if need_catch { format!(", or catch the failure (`… catch (e) => …`)") } else { String::new() } + ), + ); + } + } + } + /// R-8: `s` can be read before its first answer: an `await` with no first paint (`-> v`, R-7) in it or what it reads. + fn absent(&self, s: SId, memo: &mut HashMap, busy: &mut HashSet) -> bool { + if let Some(&b) = memo.get(&s) { + return b; + } + let Some((e, env)) = self.syms[s].expr else { return false }; + if !busy.insert(s) { + return false; + } + let mut stack = vec![Node::E(e)]; + let mut out = false; + while let Some(n) = stack.pop() { + if let Node::E(x) = n { + match &x.k { + ExprKind::Fn { .. } | ExprKind::ServerFn { .. } | ExprKind::Peek(_) => continue, + ExprKind::Await { init: None, .. } => { + out = true; + break; + } + ExprKind::Await { init: Some(_), .. } => continue, + ExprKind::Id(name) => { + if let Some(t) = self.lookup(env, name) { + if t != s && matches!(self.syms[t].kind, SymKind::Eq | SymKind::Source) && self.absent(t, memo, busy) { + out = true; + break; + } + } + continue; + } + _ => {} + } + } + stack.extend(children(n, false)); + } + busy.remove(&s); + memo.insert(s, out); + out + } + /// R-8: `e` reads `s`, directly or through the equations it reads. + fn depends(&self, e: &Expr, env: EId, s: SId, busy: &mut HashSet) -> bool { + let mut stack = vec![Node::E(e)]; + while let Some(n) = stack.pop() { + if let Node::E(x) = n { + match &x.k { + ExprKind::Fn { .. } | ExprKind::ServerFn { .. } => continue, + ExprKind::Id(name) => { + if let Some(t) = self.lookup(env, name) { + if t == s { + return true; + } + if let Some((te, tenv)) = self.syms[t].expr { + if busy.insert(t) && self.depends(te, tenv, s, busy) { + return true; + } + } + } + continue; + } + _ => {} + } + } + stack.extend(children(n, false)); + } + false + } + /// R-8: some region around `r` that is `kind` has a reader of `s` in it. + fn region_reads(&self, r: RId, s: SId, kind: impl Fn(&Region) -> bool) -> bool { + let mut cur = Some(r); + while let Some(c) = cur { + if kind(&self.regions[c]) { + for rd in &self.readers { + if self.inside(rd.region, c) && self.depends(rd.e, rd.env, s, &mut HashSet::new()) { + return true; + } + } + } + if self.regions[c].is_component_root { + break; + } + cur = self.regions[c].parent; + } + false + } + fn inside(&self, r: RId, anc: RId) -> bool { + let mut cur = Some(r); + while let Some(c) = cur { + if c == anc { + return true; + } + cur = self.regions[c].parent; + } + false + } + // ---- R-f: failures ---- fn sym_fails(&mut self, s: SId) -> Fails { let (e, env) = match &mut self.syms[s].fails_t { diff --git a/compiler/src/infer.rs b/compiler/src/infer.rs index 4aa3eff..6bbb076 100644 --- a/compiler/src/infer.rs +++ b/compiler/src/infer.rs @@ -1,8 +1,9 @@ -//! 06 AC-1b…AC-1d (`ref/src/infer.ts`): what the compiler reads off an imperative body. Which arrows are actions without -//! the keyword (AC-1b), and the two checks every imperative run gets: a plain write an inferred action holds across an -//! `await` (HELD_WRITE, AC-1c), and a read of a source the same change already wrote (STALE_OWN_READ, AC-1d). Solid sees -//! neither: a TypeScript body's promises and reads are not visible to it, which is why it asks for `action(function* …)` -//! and lets a stale read through. +//! 06 AC-1b…AC-1e (`ref/src/infer.ts`): what the compiler reads off an imperative body. Which arrows are actions without +//! the keyword (AC-1b), and the checks every imperative run gets: a plain write an inferred action holds across an +//! `await` (HELD_WRITE, AC-1c), a read of a source the same change already wrote (STALE_OWN_READ, AC-1d), and a write +//! after an `await` computed from the same source as read before it (STALE_WRITE_BACK, AC-1e). Solid sees none of them: +//! a TypeScript body's promises and reads are not visible to it, which is why it asks for `action(function* …)` and lets +//! a stale read or a lost update through. use crate::ast::{Expr, ExprKind, Field, Lit, PathSeg, Stmt, StmtKind, TplPart}; use indexmap::{IndexMap, IndexSet}; @@ -135,6 +136,7 @@ pub fn inferred_action(stmts: &[Stmt]) -> Option { pub enum Code { HeldWrite, StaleOwnRead, + StaleWriteBack, } pub struct Finding { pub code: Code, @@ -143,11 +145,17 @@ pub struct Finding { /// keys from the source; "*" a computed key type Path = Vec; +/// a read of a source: its key, the path read, and how many `await`s the run had passed when it read (AC-1e) +type Dep = (String, Path, u32); #[derive(Clone)] struct State { written: IndexMap>, held: IndexSet, dead: bool, + /// AC-1e: the source reads each local's value was computed from + taint: IndexMap>, + /// AC-1e: the `await`s passed so far on this path (the most of any path into a merge) + epoch: u32, } fn merge(ss: &[&State]) -> State { let live: Vec<&&State> = ss.iter().filter(|s| !s.dead).collect(); @@ -156,8 +164,12 @@ fn merge(ss: &[&State]) -> State { s.dead = true; return s; } - let mut out = State { written: IndexMap::new(), held: IndexSet::new(), dead: false }; + let mut out = State { written: IndexMap::new(), held: IndexSet::new(), dead: false, taint: IndexMap::new(), epoch: 0 }; for s in live { + out.epoch = out.epoch.max(s.epoch); + for (k, v) in &s.taint { + out.taint.entry(k.clone()).or_default().extend(v.iter().cloned()); + } for (k, v) in &s.written { out.written.entry(k.clone()).or_default().extend(v.iter().cloned()); } @@ -248,6 +260,7 @@ impl Walker { let after = self.walk(body, alive(st), &inner, local, &d); st.written = after.written; st.held = after.held; + st.epoch = after.epoch; } return; } @@ -296,6 +309,91 @@ impl Walker { } } } + /// AC-1e: the source reads a value is computed from now — read directly (at this epoch) or through a local's taint. + /// A closure's body runs later; an awaited answer is the server's, not a snapshot of a source. + fn deps(&self, e: &Expr, st: &State, sh: &IndexSet, out: &mut Vec) { + let mut path: Path = vec![]; + let mut x = e; + loop { + match &x.k { + ExprKind::Member { o, p, .. } => { + path.insert(0, p.clone()); + x = o; + } + ExprKind::Index { o, i, .. } => { + path.insert(0, lit_key(i)); + self.deps(i, st, sh, out); + x = o; + } + _ => break, + } + } + match &x.k { + ExprKind::Id(n) if sh.contains(n) => { + if let Some(t) = st.taint.get(n) { + out.extend(t.iter().cloned()); + } + return; + } + ExprKind::Id(n) => { + out.push((key(n, false), path, st.epoch)); + return; + } + ExprKind::Use(n) => { + out.push((key(n, true), path, st.epoch)); + return; + } + _ => {} + } + if !std::ptr::eq(x, e) { + self.deps(x, st, sh, out); + return; + } + match &x.k { + ExprKind::Fn { .. } | ExprKind::ServerFn { .. } | ExprKind::Draft { .. } | ExprKind::Pending(_) | ExprKind::Latest(_) | ExprKind::Lit(_) | ExprKind::Pre { .. } | ExprKind::Peek(_) | ExprKind::StyleMap { .. } | ExprKind::Await { .. } => {} + ExprKind::Id(_) | ExprKind::Use(_) | ExprKind::Member { .. } | ExprKind::Index { .. } => {} + ExprKind::Call { f, args } => { + self.deps(f, st, sh, out); + for a in args { + self.deps(a, st, sh, out); + } + } + ExprKind::Bin { l, r, .. } => { + self.deps(l, st, sh, out); + self.deps(r, st, sh, out); + } + ExprKind::Un { e, .. } | ExprKind::Spread(e) | ExprKind::Throw(e) => self.deps(e, st, sh, out), + ExprKind::If { c, t, f } => { + self.deps(c, st, sh, out); + self.deps(t, st, sh, out); + self.deps(f, st, sh, out); + } + ExprKind::Tpl(parts) => { + for p in parts { + if let TplPart::Expr(e) = p { + self.deps(e, st, sh, out); + } + } + } + ExprKind::Arr(items) => { + for i in items { + self.deps(i, st, sh, out); + } + } + ExprKind::Rec(fields) => { + for f in fields { + match f { + Field::Kv { v, .. } => self.deps(v, st, sh, out), + Field::Spread(e) => self.deps(e, st, sh, out), + } + } + } + ExprKind::Catch { e, alt, .. } => { + self.deps(e, st, sh, out); + self.deps(alt, st, sh, out); + } + } + } fn walk<'a>(&mut self, ss: &'a [Stmt], mut st: State, shadow: &IndexSet, cl: &Closures<'a>, depth: &[*const Expr]) -> State { let mut sh = shadow.clone(); let mut local = cl.clone(); @@ -306,7 +404,12 @@ impl Walker { match &s.k { StmtKind::Let { e, .. } | StmtKind::Expr(e) => { self.read(e, &mut st, &sh, &local, depth); + let mut d = vec![]; + if !stmt_await(s) { + self.deps(e, &st, &sh, &mut d); + } if stmt_await(s) { + st.epoch += 1; if self.action { for h in std::mem::take(&mut st.held) { self.report(Code::HeldWrite, h); @@ -317,6 +420,7 @@ impl Walker { } if let StmtKind::Let { name, .. } = &s.k { sh.insert(name.clone()); + st.taint.insert(name.clone(), d); match closure_of(s) { Some(c) => { local.insert(name.clone(), c); @@ -338,6 +442,13 @@ impl Walker { } let k = key(&target.name, target.is_ctx()); let path: Path = target.path.iter().map(|p| match p { PathSeg::Name(n) => n.clone(), PathSeg::Expr(e) => lit_key(e) }).collect(); + if matches!(s.k, StmtKind::Write { .. }) && !matches!(e.k, ExprKind::Fn { .. }) { + let mut d = vec![]; + self.deps(e, &st, &sh, &mut d); + if d.iter().any(|(dk, dp, ep)| *dk == k && *ep < st.epoch && overlaps(dp, &path)) { + self.report(Code::StaleWriteBack, k.clone()); + } + } st.written.entry(k.clone()).or_default().push(path); if matches!(s.k, StmtKind::Write { .. }) && self.inferred { st.held.insert(k); @@ -389,12 +500,12 @@ impl Walker { } } -/// AC-1c and AC-1d over one imperative run (`runFindings` in `ref/src/infer.ts`). `action`: the body is one change across +/// AC-1c, AC-1d and AC-1e over one imperative run (`runFindings` in `ref/src/infer.ts`). `action`: the body is one change across /// its awaits (AC-1); a plain run's change ends at each `await`. `inferred`: an action by AC-1b, the only kind HELD_WRITE /// applies to. `bound`: the run's parameters. Each finding once per name, in source order. pub fn run_findings(stmts: &[Stmt], action: bool, bound: &[String], inferred: bool) -> Vec { let mut w = Walker { out: vec![], said: HashSet::new(), action, inferred }; let sh: IndexSet = bound.iter().cloned().collect(); - w.walk(stmts, State { written: IndexMap::new(), held: IndexSet::new(), dead: false }, &sh, &IndexMap::new(), &[]); + w.walk(stmts, State { written: IndexMap::new(), held: IndexSet::new(), dead: false, taint: IndexMap::new(), epoch: 0 }, &sh, &IndexMap::new(), &[]); w.out } diff --git a/compiler/src/parse.rs b/compiler/src/parse.rs index e54c10a..78b0b40 100644 --- a/compiler/src/parse.rs +++ b/compiler/src/parse.rs @@ -185,10 +185,17 @@ impl<'a> Cursor<'a> { } /// A TypeScript type, kept as text: balanced up to a depth-0 stop (`=`, `:=`, `,`, `)`, `=>`, `{` when `brace_stops`). 07 §2. -fn parse_type(c: &mut Cursor, brace_stops: bool) -> R { +/// A type's text, up to where the declaration goes on. `=>` inside brackets is a function type, as it is anywhere in +/// a type alias (`arrows`); elsewhere at the top it starts a body (`fn f(): T => …`). +fn parse_type(c: &mut Cursor, brace_stops: bool, arrows: bool) -> R { let start = c.i; let mut depth: i64 = 0; while !c.eof() { + if c.starts_with("=>") && (depth > 0 || arrows) { + c.bump(); + c.bump(); + continue; + } let ch = c.peek(); if depth == 0 && (c.starts_with("//") || c.starts_with("=>") || c.starts_with(":=") || ch == '=' || ch == ',' || ch == ')' || ch == ';' || ch == '\n' || (brace_stops && ch == '{')) { break; @@ -223,7 +230,7 @@ fn opt_type(c: &mut Cursor, brace_stops: bool) -> R> { if c.peek() == ':' && c.peek_at(1) != '=' { c.bump(); c.ws(true); - return Ok(Some(parse_type(c, brace_stops)?)); + return Ok(Some(parse_type(c, brace_stops, false)?)); } Ok(None) } @@ -377,7 +384,7 @@ pub fn parse_declarations(src: &str) -> R> { c.ws(true); if c.eat("=") { c.ws(true); - parse_type(&mut c, false)?; + parse_type(&mut c, false, true)?; } else if c.starts_with("{") { let mut depth = 0; loop { @@ -605,7 +612,7 @@ fn parse_decl_at(c: &mut Cursor) -> R { c.ws(true); if c.eat("=") { c.ws(true); - parse_type(c, false)?; + parse_type(c, false, true)?; } else { c.expect("{")?; let mut depth = 1; @@ -712,7 +719,7 @@ fn parse_decl_at(c: &mut Cursor) -> R { c.ws(true); source = c.keyword("source"); c.ws(true); - ty = Some(parse_type(c, false)?); + ty = Some(parse_type(c, false, false)?); } let save = c.i; c.ws(false); @@ -1814,4 +1821,11 @@ mod recovering { assert_eq!(errs.len(), 2); assert!(parse_program(src).is_err()); } + + #[test] + fn function_types_in_props_and_type_aliases() { + let src = "type Pick = (id: number) => void\ncomponent Row(props: { id: number; onPick: (id: number) => void; onDrop: Pick }) {\n source n: number := peek(props.id)\n
  • { props.onPick(n) }}>{n}
  • \n}\nfn twice(x: number): number => x * 2\nexport default component App() {\n { log(id) }} onDrop={id => { log(id) }} />\n}\n"; + let p = parse_program(src).expect("parses"); + assert_eq!(names(&p), ["Pick", "Row", "twice", "App"]); + } } diff --git a/eval/PREREG-chain-7.md b/eval/PREREG-chain-7.md new file mode 100644 index 0000000..ae7a2e0 --- /dev/null +++ b/eval/PREREG-chain-7.md @@ -0,0 +1,64 @@ +# Round 7: maintenance chains + +Pre-registration, fixed and committed before the round's first run. Builds on `PREREG-cost.md` (round 5), +`PREREG-cost-5b.md` and `PREREG-cost-6.md`; everything there holds unless this file changes it. + +## Why + +Rounds 1 to 6 gave each agent one change to a clean app. Frontier agents wrote the guards a race needs by hand (request +numbers, guess lists, a page id) and shipped few defects in any arm, so .rx's guarantee (a stale answer cannot +overwrite a newer one; a guess holds until its write is answered) did not show. Ryan (2026-10-29): "the agents work +around the foot guns anyway. So the benefit here isn't really being shown ... let's do the maintenance tests." + +The claim tested here: hand-written guards decay when the app is changed again and again by agents who did not write +them, while in .rx there are no guards to decay. Each chain is four changes in a row, each made by a fresh agent to the +app the previous agent left, with the test suite growing as it would in a real project. + +## Chains + +- **Steps** (`tasks/chain1` … `chain4`, written by `harness/make-chain.mjs`): 1 labels, 2 open-issue count, + 3 related help (the round 5 tasks, unchanged), 4 row actions (new: `tasks/rows`, assign and close from a list row, + with a row error; it touches the guards of the desk's assign and close and the count's). +- **Step k's run** starts from step k−1's run's `src/` (step 1 from `reference/desk/`), whatever state it was + left in. `BRIEF.md` is step k's change brief; `BASE.md` is the desk brief plus the earlier steps' change briefs, in + order. The visible tests are every step's so far (22, 24, 26, 30); the hidden and overlap tests are every step's so + far (58+26, 64+29, 70+32, 77+38), so an earlier step's hidden tests become regression tests. +- **Arms**: React and .rx, as in round 5b (React plain; .rx with the guide's two pages inline, `--inline-docs`). Same + prompt template, model and effort as rounds 5 to 6. Solid and Foldkit are not run. +- **6 chains per arm**, `chain--`, n = 1..6, in `/home/claude/runs/r7`. 48 runs. +- **Batches**: each step's 12 runs (both arms) launch together, after every run of the step before has finished, so + both arms start each step in the same prompt-cache state. +- **References**: `reference/chain1..4/{react,rx}`, merged from the round 5 references plus row actions, pass every + visible, hidden and overlap test of every step (the .rx ones fail only the #4425 test, which stays alongside, as in + round 5). + +## Measures + +- **Defect at a step**: the run fails a visible, hidden or overlap test of its step (the #4425 test outside the count), + or does not build. Scored per run with `harness/score.mjs --overlap`. +- **New defect at a step**: a test that the previous step's app passed (or that is new at this step) fails. A test + already failing in the base is inherited, not new. +- **Regression**: a new defect in a test of an earlier step (the step's change broke something the agent was not + asked to touch). +- **Cost**: billed cost without each run's first call (`harness/cost-warm.mjs`), summed per chain. + +## Hypotheses and tests + +- **H7a** (primary): React chains end with a defect more often than .rx chains: chains whose step 4 app has a defect, + Fisher's exact test, one-sided, α = 0.05. With 6 chains per arm only a large gap can reach significance (e.g. 5 of 6 + against 0 of 6: p = 0.008; 4 of 6 against 0 of 6: p = 0.03; 3 of 6 against 0 of 6: p = 0.09). +- **H7b** (secondary): React runs have more new defects than .rx runs over all 24 runs per arm: Fisher's exact test on + runs with at least one new defect, one-sided. +- **H7c** (secondary): React runs have more regressions: same test on runs with at least one regression. +- Reported alongside: cost per chain and per step (one-sided permutation tests both ways, 100,000 relabellings, seed + 5); per step defects; which guards decayed (read from the failing tests and the diffs). + +The direction is stated from the claim being tested, not from a prior that it holds: in rounds 2 to 5 React shipped +as few defects as .rx. A null or reversed result is reported as plainly. + +## Limits + +- Six chains per arm: power is low for anything but a large gap. +- The step order is fixed (labels, count, related, rows); row actions come last because they reuse the most guards. +- Inherited defects make later steps' bases differ between chains; the per-step "new defect" measure is there for that. +- Published as is, whatever the outcome. diff --git a/eval/PREREG-cost-5b.md b/eval/PREREG-cost-5b.md new file mode 100644 index 0000000..dd09256 --- /dev/null +++ b/eval/PREREG-cost-5b.md @@ -0,0 +1,51 @@ +# Round 5b: does the guide close the cost gap? + +Pre-registration, fixed and committed before the round's first run. An addendum to `PREREG-cost.md` (round 5); +everything there holds unless this file changes it. + +## Why + +In round 5 (`results/cost-2026-10-29`) a change cost 30% more in .rx than in React (131k vs 101k billed units per +run), level with Solid 2 TSX (135k). Output tokens were equal; the gap was turns (9.7 vs 7.1 model calls). 16 of 18 +.rx runs searched `docs/spec/` for semantics the inline guide didn't give, listed from the transcripts: + +- count (6 of 6 runs): when an `effect` runs: under a guess, while its input loads, after a failure (spec 02 EF-2, + 04 R-2, 05). +- labels (3 of 6): whether guesses of two actions revert apart (05 H-7, H-11; 06 AC-3). +- related (1 of 6): `peek` (02). + +The guide covered the second and third already. It said nothing on the first beyond "runs after `count` changes". + +## Change + +One change between round 5 and 5b: `guide/async.md` gains section 6, "Effects: acting on a value as it changes" +(when an effect runs, loading and failing inputs, guesses, lifetime, order), and `guide/language.md`'s effect line +points to it. About 400 extra tokens in the prompt. The section states language rules; it has no task code and +names no task's elements. Everything else is round 5's: bases, briefs, tests (same SHA-256 as listed there), +prompt template, toolchain (Solid next 5e68925c), checker, model at default effort, the one-line start pointing to +`.prompt`. + +## Runs + +The .rx arm only: 3 tasks × 6 = 18 runs, `/home/claude/runs/r5b/-rx-`, scaffolded as in round 5 +(`scaffold.mjs rx --brief plain --base reference/desk/rx --inline-docs`), launched by task as in round +5. React and Solid are not rerun: their prompts and docs did not change. + +## Hypotheses and tests + +- **H1b** (primary): the change costs less in .rx (5b) than in React (round 5's 18 runs). One-sided permutation test + on the difference of means, 100,000 relabellings, seed 5, α = 0.05 (`harness/cost.mjs` over a list of round 5's + React runs and 5b's .rx runs). +- **H3** (secondary): .rx 5b costs less than .rx 5, same test. This is the guide's effect. +- Reported alongside: Solid − .rx 5b; per task cost and calls; doc lookups per run (tool calls that open + `docs/`); defects as in round 5, with #4425's test outside the count. + +## Limits + +- Not concurrent with the baseline: the React and Solid runs are from earlier the same day. Same model, effort, + toolchain and harness; serving-side drift can't be ruled out. +- The guide section was written after seeing what round 5's agents looked up, so it answers questions these tasks + raise. It answers them as rules of the language, the way any user-facing guide would; whether it is "tuned" is + for the reader to judge from the diff. A win here says how much documenting semantics is worth, not that .rx is + cheaper by itself. +- Published as is, whatever the outcome. diff --git a/eval/PREREG-cost-6.md b/eval/PREREG-cost-6.md new file mode 100644 index 0000000..ec78d49 --- /dev/null +++ b/eval/PREREG-cost-6.md @@ -0,0 +1,65 @@ +# Round 6: what a change costs in Foldkit + +Pre-registration, fixed and committed before the round's first run. An addendum to `PREREG-cost.md` (round 5) and +`PREREG-cost-5b.md`; everything there holds unless this file changes it. + +## Why + +Ryan (2026-10-29) asked whether Foldkit shows the same effect. Foldkit (foldkit.dev) is The Elm Architecture in +TypeScript on Effect: one Model, messages, a pure update, side effects as Commands. Its pitch includes AI-assisted +development: every change follows the same visible structure. It is also newer than the model's training, like .rx +and Solid 2, but asks for much more code per feature. Round 5 found that cost follows turns spent on semantics the +agent is unsure of, not source size. Foldkit tests the other side: a framework with explicit, conventional +structure and no reactive timing to look up, at about twice React's code. + +## Arm + +- **Foldkit** 0.170.0 with effect 4.1.0 and @effect/platform-browser 4.1.0 (`eval/package.json`). +- **Bases** (`reference/desk/foldkit`, `reference/{labels,related,count}/foldkit`): written for this round by the + same author as the other arms, following Foldkit's `FOLDKIT.md`: a root `main.ts` with a page union and + Submodel views, one module per page (`issues.ts`, `help.ts`, `settings.ts`), Commands per API call, request + sequence numbers to drop stale answers, a `pageId` on page messages to drop a discarded page's answers. Desk + 1,147 lines / 42 KB (React 570 / 20 KB, Solid 419 / 15 KB, .rx 315 / 11 KB). Every Foldkit reference passes every + visible, hidden and overlap test of its task, including the #4425 test that the .rx and Solid references fail. +- **Docs**: Foldkit's own agent conventions file, `FOLDKIT.md` from `create-foldkit-app` at the release tag (about + 5k tokens), at the end of the prompt (as the Solid arm gets Solid's primer). The Foldkit repository at tag + `foldkit@0.170.0` (examples, skills, `AGENTS.md`, the framework source) is readable in the run's `docs/foldkit`, + which is what `FOLDKIT.md` tells agents to vendor. +- **Checker**: `tsc --noEmit` with Foldkit's template `tsconfig` (strict, `noUncheckedIndexedAccess`, + `exactOptionalPropertyTypes`). Foldkit's lint (oxlint with its plugin and effect-tsgo) is not published on npm, + so the arm has none; React and Solid have ESLint. +- **Build**: the harness boots `src/main.ts`'s `Model`, `init`, `update`, `view` with `Runtime.makeApplication` and + `Runtime.run`, as Foldkit's `entry.ts` does. + +## Runs + +3 tasks × 6 = 18 Foldkit runs, `/home/claude/runs/r6/-foldkit-`, scaffolded with +`scaffold.mjs foldkit --brief plain --base reference/desk/foldkit --inline-docs`, launched together. +Same tasks, briefs, hidden tests (SHA-256 unchanged), prompt template, model and effort as rounds 5 and 5b. React, +Solid and .rx are not rerun. + +## Measure + +Round 5b found that a run's first call (the same text in every run: system prompt, tools, the one-line start) is +priced by the prompt cache's state when the batch starts, not by the arm. This round's primary measure is therefore +**billed cost without the first call** (`harness/cost-warm.mjs`), compared with round 5's React and Solid runs and +5b's .rx runs on the same measure. Raw billed cost is reported beside it. + +## Hypotheses and tests + +- **H6a** (primary): a change costs more in Foldkit than in React. One-sided permutation test (Foldkit − React), + 100,000 relabellings, seed 5, α = 0.05. +- **H6b** (secondary): a change costs more in Foldkit than in .rx (5b). Same test. +- Reported alongside: Foldkit − Solid; per task cost, model calls, output tokens, doc lookups (`docs/` reads); + defects as in round 5 (the #4425 test stays outside the count; Foldkit's reference passes it). + +The direction is stated in advance from the author's prior (Foldkit: more code to write per change, and unfamiliar). +A Foldkit mean at or below React's is reported as plainly. + +## Limits + +- Not concurrent with the baselines (rounds 5 and 5b ran earlier the same day); same model, effort, toolchain. +- The Foldkit bases were written by someone who had not used Foldkit before this round, from its docs and examples; + an experienced Foldkit author might write them shorter. +- No lint in the Foldkit arm (above). +- Published as is, whatever the outcome. diff --git a/eval/PREREG-cost.md b/eval/PREREG-cost.md new file mode 100644 index 0000000..7101a65 --- /dev/null +++ b/eval/PREREG-cost.md @@ -0,0 +1,118 @@ +# Round 5: what a change costs + +Pre-registration. Everything in this file was fixed and committed before the round's first agent run. + +## Why this round + +Rounds 2 to 4 measured defects and found no gap that holds up (`results/`). Ryan's question for this round is cost: +does an agent spend fewer tokens changing an app written in .rx than the same app written in React? In round 4 it +spent more (billed cost about 1.5× React's), mostly on reading the .rx docs from disk and on failed checks. A 9-run +pilot with the docs in the prompt (`scaffold --inline-docs`, `guide/language.md` + `guide/async.md`) cost about 9% +less than React (81k vs 89k billed units, p = 0.13 one-sided, not pre-registered). Those apps were small (one file, +about 100 lines), so most of the cost was the fixed prompt; this round's base app is bigger, which is where .rx's +smaller source should count. + +## Design + +- **Base**: the issue desk, `reference/desk/` (brief `tasks/desk/brief-plain.md`, given to the agent as + `BASE.md`): three pages behind tabs (issues with assign, rename, close and comments; help search; settings), the + six earlier tasks in one app. .rx 315 lines in 6 files, Solid 2 TSX 419 lines in 6 files, React 570 lines in 9 + files. All three pass the desk's 90 tests on the round's toolchain. +- **Arms**: .rx; React 19 + TypeScript (TanStack Query available); Solid 2 TSX (added 2026-10-28, Ryan), which asks + whether a .rx difference is the language's or Solid's: Nolan Lawson's benchmark found SolidStart no cheaper than + React. The Solid base follows the .rx base's design (same stores, actions and boundaries), written as a Solid 2 + author would. +- **Tasks** (`tasks//brief-plain.md`; none names a race): + - labels: add and remove the selected issue's labels (`fetchLabels`, `addLabel`, `removeLabel`), straight away, + reverted if declined. A feature inside one page. + - related: the selected issue's related help (`search` on its title); clicking one opens the help page on it. A + change across pages. + - count: the open-issue count on the issues tab, live through closes, kept on other pages. State lifted out of a + page. +- **Tests**: per task `visible.mjs` (the desk's 19 plus 2 or 3 new), `hidden.mjs` (the desk's hidden tests as + regressions plus 6 to 8 new) and `hidden-overlap.mjs` (the desk's overlap tests plus 3 to 5 new). References + (`reference//`, not subjects) pass all of them in all three arms. +- **Prompt**: `harness/prompt-change.md`, with `--inline-docs`: the .rx arm gets `guide/language.md` and + `guide/async.md` (about 3k tokens) at the end of its prompt; the Solid arm gets Solid's own agent primer, + `solid-js/skills/solid-2.0/SKILL.md` (about 2.4k tokens), with the full cheatsheet and skills readable in + `node_modules/solid-js`; React gets nothing (it needs nothing). Checkers: `rxc check`; tsc strict plus ESLint + react-hooks for React; tsc strict plus eslint-plugin-solid's `v2` config for Solid. Each run keeps scratch files in its own + `.scratch` folder and uses no messaging tools. Each run's agent is started with one line that points it to its + `.prompt` file, which it reads first: one extra call, the same in every arm (the prompts are too long to relay + by hand 54 times). +- **Model**: the frontier model only, at the agent harness's default effort; the model id and effort are recorded with + the results (cost is the question; round 4's small-model runs add variance, not an answer). +- **Runs**: 3 tasks × 3 arms × 6 runs = 54. Run k of a task starts from the same base in its arm. +- **Toolchain**: Solid next @ 5e68925c (2.0.0-rc.20, `ref/SOLID_NEXT`) in all three arms, through + `eval/package.json`; the .rx compiler and runtime from this branch at the freezing commit. All references pass all + tests on it but one, below. +- **Not waited on**: runtime bugs. Each round so far found Solid bugs, and fixing them before each round would mean + no round. A bug found before freezing is named here and its test sits outside the defect count; one found during + the round is listed, and the defect results are given with and without it. Neither changes the primary measure: + the hidden tests are never shown to the agent, so they cannot change what a run costs. + +Hidden test files (round 5's and the earlier ones they import), SHA-256 at freezing: + +``` +bdb6830b286c16c5d28e9b7789c138954465c15a1fd37c3b9422efb02434573e tasks/desk/hidden-overlap.mjs +fa881652fa8ea2dcd3dc9ca0edbb33c705374a4f625670c043c07d29f63da0b8 tasks/desk/hidden.mjs +cf1c1eb9e58cf2aae31a74125c4eab0edb7d8d67134a7cd74dc5a0ebe32e2fa5 tasks/labels/hidden-overlap.mjs +89be40f794261e4b42090fdabdb38c1e48a32e1dbd811ec25ae3b285cac26cf2 tasks/labels/hidden.mjs +546ac1ee33c81f2909a23cc6e2ae4de3588678318ce6aa01be987519e160733f tasks/related/hidden-overlap.mjs +bd714cbb344aae7a6aa3a52132bd4b61fc860729d2e96e64ed8b11b69230144d tasks/related/hidden.mjs +9dc4b040fa5e5b7c971aa1d9954d194d6dde09c1ec69f71fd872e990ad91c9ef tasks/count/hidden-overlap.mjs +1270f72e51d84409cc83fc76299ba5d9621600312dc71c2b9664bbbf6289e42e tasks/count/hidden.mjs +84ecb6574905d44ccdf9b4f3b68cb4be29769b32f5a6415b16307da15f695e93 tasks/desk/pages.mjs +5d3e09edb9f0c2845aba82fc43b9689c8fe1066167fefa2dd04cbb93f4711b3e tasks/tracker/hidden-overlap.mjs +c02e80f9b64e44dd843b601713ddc67553a5f85835e36ee5d13c35fb4121d09e tasks/tracker/hidden.mjs +532e2caf837f3effaf77c5979af2651ed4d0ec2973b496d76846035d45831403 tasks/rename/hidden-overlap.mjs +869e02eceb679c5907a67571a26f061157eefd86e8e38a1a7aec8db7d9e65fc0 tasks/rename/hidden.mjs +669c606395e539e031ca7f376304a42607d15ffccb823b291b5d616f30ddce42 tasks/close/hidden-overlap.mjs +86aebac10d51c9244504e5da3b368c356e6b8c65dfab5d9dbc35b03adb66604d tasks/close/hidden.mjs +11dd32de3c947f059b2a84216b1ac579e84666ecaba50d8cef44a23d2d09e249 tasks/comments/hidden-overlap.mjs +25d241a2309310943ed24d72d26c2a7e638a6feecbd3397b39123ebf5e956dab tasks/comments/hidden.mjs +5776f8888055c4f5c13261b8637e048ebbb4f919602afb6f294a78ae4339b4d2 tasks/search/hidden-overlap.mjs +1d77a1daa81d15d40045d1f4665f9fdcebbe9abb6356db5b3a6eb8b29d30d0ae tasks/search/hidden.mjs +aa1d42209d188ea4c54220c816d0ffe718e7ebe65c2d9b9d3fbf5fa23949aade tasks/settings/hidden-overlap.mjs +58d2a1daa4a1b8d2ea559ab56bb37ee9193dcf3d31d03e362107e25ee3b106b6 tasks/settings/hidden.mjs +``` + +## Hypothesis and test + +- **H1**: changing the desk costs less in .rx than in React. +- **H2** (secondary): changing the desk costs less in .rx than in Solid 2 TSX. +- **Primary measure**: billed cost per run, from the run's transcript (`node harness/cost.mjs `): + input × 1 + cache write × 1.25 + cache read × 0.1 + output × 5, in uncached-input-token units (the API's price + ratios), over all of the run's model calls. One number per run, pooled over tasks (18 runs per arm). +- **Primary test**: one-sided permutation test on the difference of means (React − .rx), 100,000 relabellings, + α = 0.05. H1 holds if .rx's mean is lower and p < 0.05. A higher .rx mean is reported as plainly. H2 is tested the + same way (Solid − .rx) and reported as secondary, with React − Solid beside it, descriptive only. +- **Power**: the pilot's per-run sd was 6.8k (React) and 18.2k (.rx), on means near 85k; with 18 runs per arm the + test has about 80% power for a difference of 14%. A smaller true difference can be missed. +- **Outside the defect count, reported alongside**: "base: retry after not found shows the issue" (related task): + solidjs/solid#4425, filed 2026-10-29: on rc.20 an `` recovering after a retry waits for async under a + nested ``. The .rx and Solid references of the related task fail it; React's passes. Also round 4's + "a post on one issue does not show on another" (solidjs/solid#4336, fixed; kept outside for comparability). +- **Secondary, reported alongside**: + - defects shipped (round 4's measure: build, visible, hidden and overlap tests at "done"), per arm, Fisher's exact + test two-sided; cost per run that shipped no defect; + - per task: cost, model calls, output tokens, `./check` and `./test` cycles; + - the cost split: the fixed prompt (system, tools, brief, and for .rx the inline docs), reading the app, and the + agent's own work (output and the turns it takes); + - wall-clock time per run; + - medians beside the means (a run that wanders off moves a mean, not a median), and the cost in API dollars at the + prices of the day, as Claude Code reports it, so the numbers compare with other published framework cost + benchmarks (Nolan Lawson, "The AI cost of JavaScript frameworks", 2026-10-28, which builds an app from scratch + in 12 setups; this round changes an existing one). +- Published as is, whatever the outcome. + +## Known limits + +- The bases and tasks were written by the person who wrote the .rx references and the language; the briefs and tests + are the same for both arms, but the bases are not agent-written. +- Isolation is by instruction (prompt), checked afterwards in the transcripts. +- The .rx and Solid arms carry docs in the prompt (about 3k and 2.4k tokens) and React carries none: that is the real + cost of something newer than the model's training and stays in the measure. The Solid arm's docs are Solid's own, + unedited, so they are not tuned to these tasks; the .rx guide was written during these rounds (without task code). +- Prices are ratios at the time of the round; a different cache price changes the weights, so raw token counts are + published per run too. diff --git a/eval/PREREG-fuzz-8.md b/eval/PREREG-fuzz-8.md new file mode 100644 index 0000000..107e06c --- /dev/null +++ b/eval/PREREG-fuzz-8.md @@ -0,0 +1,101 @@ +# Round 8: an async timing fuzzer over the finished apps + +Pre-registration, fixed and committed before the fuzzer runs on any agent's app. + +## Why + +Rounds 2-7 graded apps with hand-written hidden tests. Frontier agents guard the races a careful person would test, +so those tests found few holes in any arm. Ryan (2026-10-30): agents build to the task; a hole nobody thought of is +never found, by the agent or by the grader. .rx's claim is correctness by construction rather than by diligence, so +the grader has to look for holes nobody wrote a test for. No new agent runs: the fuzzer runs on the apps rounds 5-7 +already produced. + +## The fuzzer (`harness/fuzz.mjs`) + +- **Session**: from a seed, 30 random steps on the issue desk, then every request still in the air answered in random + order. A step is either a user move drawn from what is on screen (select a row, change the user, assign, rename, + close, row assign, row close, add or remove a label, post a comment, switch the filter, leave for the help page, + come back) or, with probability 0.5 while requests are in the air, an answer to one of them, chosen at random: + writes are declined with probability 0.3, reads fail with probability 0.1. +- **Fake server**: keeps the truth (issues, comments, labels) and applies a write when its answer lands. +- **Oracle, every frame** (after each step, once the page settles): on the issues page, each row shows the server's + title and assignee or the value of a write still in the air; the open list shows no issue the server has closed and + misses no open issue unless a close of it is in the air; the closed list shows no open issue; the detail shows the + selected issue, with the server's title or one being written. A page error is a hole. +- **Oracle, at rest** (nothing in the air, at the end): the list shows exactly the server's issues for the filter, in + order, with the server's titles and assignees; the tab count equals the open rows shown; the selected issue's + heading, labels and comments match the server. +- **Allowances**, the same for every arm, each from a brief or a ruling: + - A1 (H-11, ruled desired 2026-10-28): a declined write's value, or a declined close's missing row, may show while + anything is still in the air. + - A2 (row actions brief, point 5): with several writes to one field accepted in a busy period, the field may show the + newest started instead of the one the server took last. + - A3: the count is checked only at rest, on the issues page, with the open list on screen. + - A4 (desk brief: a discarded page's requests change nothing): a write made on a page since left may land without + showing on the new page. + - A5 (Ryan, 2026-10-28: an input change under a pending action holds): a filter switch or a selection made while + requests are in the air may keep showing the previous list or detail until nothing is in the air. +- A session stops at its first hole; the result per app is the number of seeds (of 50) that found one, with the first + hole and the steps that led to it. + +Validation before this file: on the chain 4 references (all features), the React reference showed 0 holes in 25 seeds +before a fuzzer fix for repeated request keys; the .rx reference showed 17 of 50, including a page error in our web +runtime (an imperative read of a pending equation, `shown.includes(...)`, while labels load) and accepted closes whose +row reappears while other closes are out. These are reported, not tuned away. + +## Apps + +Every desk-family run directory from rounds 5-7 that builds: round 5 React, Solid and .rx (labels, related, count; +54), round 5b .rx (18), round 6 Foldkit (18), round 7 chains React and .rx (48). Seeds 1-50, 30 steps, for every app. + +## Hypotheses and tests + +- **H8a** (primary): React apps have more holes than .rx apps. Per app hole rate (seeds with a hole / 50), React + (round 5 + round 7, 42 apps) against .rx (round 5, 5b and round 7, 60 apps); one-sided permutation test on the + means, 100,000 relabellings, seed 5. +- **H8b**: more React apps than .rx apps have at least one hole; Fisher's exact test, one-sided. +- Reported alongside: Solid and Foldkit rates; holes by kind; per round and task; the reverse direction of H8a. + +The direction is the claim being tested. A null or reversed result is reported as plainly. + +## Limits + +- The oracle encodes my reading of the briefs; the allowances can hide a hole that a stricter reading would count. +- Features differ by task (labels only in labels and chain apps, count only in count and chain 2+ apps), so per-app + rates are compared within the same task family as well as overall. +- Sessions are random, not exhaustive; 50 seeds per app. +- Published as is, whatever the outcome. + +## Addendum (before any final run, after a partial first run) + +A first run over 59 round 5 apps was stopped and discarded. Two oracle errors it exposed were fixed, both applying to +every arm: + +- at rest, the closed list need not gain an issue closed after it loaded (no brief asks for it); it must still hold no + open issue. This was the only kind of hole found in React apps; +- A5 kept the selection or filter from before the latest click instead of the one before the busy period began. + +Replays confirmed two kinds the partial run found in the .rx apps are real and stay counted: switching the filter +back to open shows the closed rows with no `loading open issues` while the open list reloads, and an accepted rename's +title flashes back to the old one until its refresh answers. Results come from a full rerun with the fixed fuzzer. + +A second full run was also discarded, because A5 was implemented more narrowly than written: it allowed only the +view from before the first click of a busy period. It now allows any filter or selection the user left during the +busy period, and a held list may keep rows that were on screen when the switch was made (values in a held view are not +checked). This only removes holes, and only from apps that hold views (the .rx and Solid apps). Final results come +from a third run with this fuzzer. + +## Addendum 3 (after the final run, before reading per-arm results into anything) + +Replaying holes to classify them found two fuzzer faults, both fixed for every arm: + +- A2 tracked a write by its request key, so a second identical write asked while the first was in the air (rename + Bravo, rename Alpha, rename Bravo) kept its first start position: the oracle took Alpha as the newest started and + flagged a detail showing Bravo. A re-asked write now moves to the newest started. This produced all 5 React + `detail title` holes (chain 3). +- A page that crashed or hung the browser stopped the whole run for that app (chain4-rx-2 never finished). A crash + or a session over 120 s is now a hole of its own (`page crash`) and the next seed gets a fresh browser. + +Both changes only remove holes or turn a crash into one, so an app with no hole in the final run cannot change. +The 101 apps with at least one hole, and chain4-rx-2, were run again with the fixed fuzzer (same seeds, the +compiler as committed at 1246c76); the results report that run, and the final run's numbers beside it. diff --git a/eval/harness/build.mjs b/eval/harness/build.mjs index c816072..f3deca2 100644 --- a/eval/harness/build.mjs +++ b/eval/harness/build.mjs @@ -1,6 +1,10 @@ // Builds one app (a run directory or a reference) into `/.build/dist` with Vite, for the tests to drive. // rx arm: `rxc compile src/main.rx` → JavaScript over Solid rc.19, mounted with the .rx runtime (ref/compile/rt-web.ts) // react arm: `src/App.tsx` (default export) mounted with createRoot under StrictMode, as Vite's React template does +// solid arm: `src/App.tsx` (default export) compiled by @solidjs/compiler (Solid 2's JSX compiler) and mounted with +// @solidjs/web's `render` +// foldkit arm: `src/main.ts` exports Model, init, update and view; booted with Runtime.makeApplication and +// Runtime.run, as Foldkit's own `src/entry.ts` does // The app's API is `src/api.js` over `src/flights.js`: every call is a flight the test lands or fails. // Usage: node harness/build.mjs [rx|react] (the arm defaults to what `/arm` says) import { execFileSync } from "node:child_process"; @@ -15,6 +19,19 @@ export const RT_WEB = process.env.RT_WEB ?? path.resolve(evalDir, "../ref/compil export const armOf = dir => readFileSync(path.join(dir, "arm"), "utf8").trim(); +// Solid's JSX compiler runs before Vite's esbuild pass, which then strips the types it leaves in +async function solidPlugin() { + const { transform } = (await import("node:module")).createRequire(import.meta.url)(path.join(evalDir, "node_modules/@solidjs/compiler")); + return { + name: "solid-jsx", enforce: "pre", + transform(code, id) { + if (!/\.[jt]sx$/.test(id) || id.includes("node_modules")) return null; + const r = transform(code, { filename: id, generate: "dom", sourceMap: true }); + return { code: r.code, map: r.map }; + }, + }; +} + export async function build(dir, arm = armOf(dir)) { dir = realpathSync(path.resolve(dir)); const work = path.join(dir, ".build"); @@ -27,17 +44,22 @@ export async function build(dir, arm = armOf(dir)) { try { execFileSync(RXC, ["compile", path.join(dir, "src/main.rx"), "--out", path.join(work, "rx")], { stdio: "pipe" }); } catch (e) { return { ok: false, stage: "compile", output: String(e.stdout) + String(e.stderr) }; } entry = `import { createApp } from ${JSON.stringify(RT_WEB)};\nimport main from "./rx/main.mjs";\nimport * as api from ${JSON.stringify(path.join(dir, "src/api.js"))};\nconst app = createApp({ root: document.getElementById("app"), api });\napp.mount(main(app));\n`; + } else if (arm === "solid") { + entry = `import { render } from "@solidjs/web";\nimport { createComponent } from "solid-js";\nimport App from ${JSON.stringify(path.join(dir, "src/App.tsx"))};\nrender(() => createComponent(App, {}), document.getElementById("app"));\n`; + } else if (arm === "foldkit") { + entry = `import { Runtime } from "foldkit";\nimport { Model, init, update, view } from ${JSON.stringify(path.join(dir, "src/main.ts"))};\nRuntime.run(Runtime.makeApplication({ Model, init, update, view, container: document.getElementById("app") }));\n`; } else { entry = `import { StrictMode, createElement } from "react";\nimport { createRoot } from "react-dom/client";\nimport App from ${JSON.stringify(path.join(dir, "src/App.tsx"))};\ncreateRoot(document.getElementById("app")).render(createElement(StrictMode, null, createElement(App)));\n`; } writeFileSync(path.join(work, "entry.js"), entry); writeFileSync(path.join(work, "index.html"), `app
    \n`); const vite = await import(pathToFileURL(path.join(evalDir, "node_modules/vite/dist/node/index.js")).href); - const plugins = arm === "react" ? [(await import(pathToFileURL(path.join(evalDir, "node_modules/@vitejs/plugin-react/dist/index.js")).href)).default()] : []; + const plugins = arm === "react" ? [(await import(pathToFileURL(path.join(evalDir, "node_modules/@vitejs/plugin-react/dist/index.js")).href)).default()] + : arm === "solid" ? [await solidPlugin()] : []; try { await vite.build({ root: work, configFile: false, logLevel: "silent", base: "./", plugins, cacheDir: path.join(work, ".vite"), - resolve: { dedupe: ["solid-js", "@solidjs/web", "@solidjs/signals", "react", "react-dom"] }, + resolve: { dedupe: ["solid-js", "@solidjs/web", "@solidjs/signals", "react", "react-dom", "effect", "foldkit"] }, build: { outDir: "dist", emptyOutDir: true, minify: false }, }); } catch (e) { return { ok: false, stage: "bundle", output: String(e.message ?? e) }; } diff --git a/eval/harness/chain.mjs b/eval/harness/chain.mjs new file mode 100644 index 0000000..44abfac --- /dev/null +++ b/eval/harness/chain.mjs @@ -0,0 +1,103 @@ +// Round 7 (maintenance chains, PREREG-chain-7.md): per run, the tests it fails, which of them are new (passed in its +// base, the previous step's app, or are new at this step) and which of the new ones are regressions (an earlier +// step's test); per chain, whether its last app has a defect and its cost (billed without each run's first call). +// Usage: node harness/chain.mjs [ ] [--out ] +import { readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { WEIGHTS, permutation } from "./cost.mjs"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const tasks = path.resolve(here, "../tasks"); +const args = process.argv.slice(2); +const o = args.indexOf("--out"); +const out = o >= 0 ? args.splice(o, 2)[1] : null; +const [scoreFile, list, dir] = args; +const score = JSON.parse(readFileSync(scoreFile, "utf8")); + +// every test name of step k's suites, and the step each test first appears in +const suite = {}; +const firstStep = {}; +for (let k = 1; k <= 4; k++) { + suite[k] = new Set(); + for (const f of ["visible", "hidden", "hidden-overlap"]) { + for (const t of (await import(pathToFileURL(path.join(tasks, `chain${k}`, `${f}.mjs`)).href)).default) { + suite[k].add(`${f === "visible" ? "visible" : "hidden"}: ${t.name}`); + firstStep[`${f === "visible" ? "visible" : "hidden"}: ${t.name}`] ??= k; + } + } +} + +const failing = r => r.built === false ? new Set(["build"]) : new Set([ + ...r.failedVisible.map(t => `visible: ${t.name}`), + ...r.failedHidden.map(t => `hidden: ${t.name}`), + ...(r.failedOverlap ?? []).map(t => `hidden: ${t.name}`), +]); +const byRun = Object.fromEntries(score.map(r => [r.run, r])); + +let bill = null; +if (list) { + const b = u => (u.input_tokens ?? 0) * WEIGHTS.input + (u.cache_creation_input_tokens ?? 0) * WEIGHTS.cacheWrite + + (u.cache_read_input_tokens ?? 0) * WEIGHTS.cacheRead + (u.output_tokens ?? 0) * WEIGHTS.output; + bill = {}; + for (const [run, id] of readFileSync(list, "utf8").trim().split("\n").map(l => l.trim().split(/\s+/))) { + const seen = new Set(); const calls = []; + for (const line of readFileSync(path.join(dir, `agent-${id}.jsonl`), "utf8").split("\n")) { + let e; try { e = JSON.parse(line); } catch { continue; } + if (e.message?.usage && !seen.has(e.message.id)) { seen.add(e.message.id); calls.push(e.message.usage); } + } + bill[run] = calls.slice(1).reduce((s, u) => s + b(u), 0); + } +} + +const runs = []; +for (const r of score) { + const [, k, arm, n] = r.run.match(/^chain(\d)-(\w+)-(\d+)$/).map((x, i) => (i === 1 || i === 3 ? Number(x) : x)); + const fails = failing(r); + const base = k > 1 ? byRun[`chain${k - 1}-${arm}-${n}`] : null; + const baseFails = base ? failing(base) : new Set(); + const isNew = t => t === "build" ? !baseFails.has("build") : !(baseFails.has(t) || (baseFails.has("build") && firstStep[t] < k)); + const fresh = [...fails].filter(isNew); + const regressions = fresh.filter(t => t !== "build" && firstStep[t] < k); + runs.push({ run: r.run, step: k, arm, chain: n, defect: fails.size > 0, failing: [...fails], new: fresh, regressions, cost: bill?.[r.run] ?? null }); +} +runs.sort((a, b) => a.arm.localeCompare(b.arm) || a.chain - b.chain || a.step - b.step); + +/** one-sided Fisher: p that the first group has at least `a` of the `a + c` hits by chance */ +function fisherGreater(a, n1, c, n2) { + const lf = x => { let s = 0; for (let i = 2; i <= x; i++) s += Math.log(i); return s; }; + const choose = (n, k) => lf(n) - lf(k) - lf(n - k); + const hits = a + c; let p = 0; + for (let x = a; x <= Math.min(hits, n1); x++) p += Math.exp(choose(n1, x) + choose(n2, hits - x) - choose(n1 + n2, hits)); + return Math.min(1, p); +} + +for (const r of runs) console.log(`${r.run.padEnd(16)} ${r.defect ? "DEFECT" : "ok "} new ${r.new.length} regressions ${r.regressions.length}${r.cost != null ? ` cost ${Math.round(r.cost)}` : ""}${r.new.length ? `\n ${r.new.join("\n ")}` : ""}`); +const arms = ["react", "rx"]; +const last = Math.max(...runs.map(r => r.step)); +const report = { last, arms: {} }; +for (const a of arms) { + const rs = runs.filter(r => r.arm === a); + const chains = [...new Set(rs.map(r => r.chain))]; + report.arms[a] = { + chains: chains.length, + endDefect: chains.filter(c => rs.find(r => r.chain === c && r.step === last)?.defect).length, + runs: rs.length, + runsWithNew: rs.filter(r => r.new.length).length, + runsWithRegression: rs.filter(r => r.regressions.length).length, + perStep: Object.fromEntries([1, 2, 3, 4].filter(k => rs.some(r => r.step === k)).map(k => [k, { + defect: rs.filter(r => r.step === k && r.defect).length, new: rs.filter(r => r.step === k && r.new.length).length, + ...(bill ? { meanCost: Math.round(rs.filter(r => r.step === k).reduce((s, r) => s + r.cost, 0) / rs.filter(r => r.step === k).length) } : {}), + }])), + ...(bill ? { chainCost: chains.map(c => Math.round(rs.filter(r => r.chain === c).reduce((s, r) => s + r.cost, 0))) } : {}), + }; +} +const [R, X] = arms.map(a => report.arms[a]); +report.H7a = { react: `${R.endDefect}/${R.chains}`, rx: `${X.endDefect}/${X.chains}`, p: fisherGreater(R.endDefect, R.chains, X.endDefect, X.chains) }; +report.H7b = { react: `${R.runsWithNew}/${R.runs}`, rx: `${X.runsWithNew}/${X.runs}`, p: fisherGreater(R.runsWithNew, R.runs, X.runsWithNew, X.runs) }; +report.H7c = { react: `${R.runsWithRegression}/${R.runs}`, rx: `${X.runsWithRegression}/${X.runs}`, p: fisherGreater(R.runsWithRegression, R.runs, X.runsWithRegression, X.runs) }; +if (bill) { + report.cost = { reactMinusRx: permutation(R.chainCost, X.chainCost), rxMinusReact: permutation(X.chainCost, R.chainCost) }; +} +console.log(JSON.stringify({ ...report, runs: undefined }, null, 1)); +if (out) writeFileSync(out, JSON.stringify({ ...report, runs }, null, 2) + "\n"); diff --git a/eval/harness/check.mjs b/eval/harness/check.mjs index abdee51..e27b04e 100644 --- a/eval/harness/check.mjs +++ b/eval/harness/check.mjs @@ -1,6 +1,8 @@ // The arm's compiler feedback, as the agent runs it. // rx: rxc check src/main.rx // react: tsc --noEmit -p . and eslint (react-hooks rules) +// solid: tsc --noEmit -p . and eslint (eslint-plugin-solid's v2 config) +// foldkit: tsc --noEmit -p . with Foldkit's template tsconfig (its lint, oxlint + effect-tsgo, is not on npm) // Usage: node harness/check.mjs import { execFileSync } from "node:child_process"; import { existsSync, symlinkSync } from "node:fs"; @@ -22,7 +24,9 @@ export function check(dir) { if (armOf(dir) === "rx") return run(RXC, ["check", "src/main.rx"], dir); const bin = n => path.join(evalDir, "node_modules/.bin", n); const tsc = run(bin("tsc"), ["--noEmit", "-p", "."], dir); - const lint = run(bin("eslint"), ["--no-warn-ignored", "-c", path.join(here, "eslint.config.mjs"), "src"], dir); + if (armOf(dir) === "foldkit") return { ok: tsc.ok, out: tsc.out.trim() || "ok" }; + const config = armOf(dir) === "solid" ? "eslint-solid.config.mjs" : "eslint.config.mjs"; + const lint = run(bin("eslint"), ["--no-warn-ignored", "-c", path.join(here, config), "src"], dir); return { ok: tsc.ok && lint.ok, out: `${tsc.out}${lint.out}`.trim() || "ok" }; } diff --git a/eval/harness/cost-split.mjs b/eval/harness/cost-split.mjs new file mode 100644 index 0000000..9310512 --- /dev/null +++ b/eval/harness/cost-split.mjs @@ -0,0 +1,42 @@ +// Round 5 secondary measures per run, from the transcripts: model calls, output tokens, the first call's context +// (system, tools and the one-line start), ./check and ./test calls, doc reads, messaging-tool calls, wall-clock, API $. +// Usage: node harness/cost-split.mjs +import { readFileSync, writeFileSync } from "node:fs"; +const [list, dir, outFile] = process.argv.slice(2); +// API $ per uncached input token at the day's frontier-model price ($5 per million); billed units are in input tokens +const PRICE = 5e-6; +const runs = readFileSync(list,"utf8").trim().split("\n").map(l=>l.split(/\s+/)); +const out = []; +for (const [run,id] of runs) { + const lines = readFileSync(`${dir}/agent-${id}.jsonl`,"utf8").split("\n").filter(Boolean).map(l=>{try{return JSON.parse(l)}catch{return null}}).filter(Boolean); + const seen=new Set(); let first=null, t0=null, t1=null; let checks=0, tests=0, reads=0, readsDocs=0, mcp=0, tools={}; + let calls=[]; + for (const e of lines) { + if (e.timestamp) { const t=Date.parse(e.timestamp); if(t0===null||tt1)t1=t; } + const m=e.message; if(!m) continue; + if (m.usage && !seen.has(m.id)) { seen.add(m.id); calls.push(m.usage); } + if (e.type==="assistant" && Array.isArray(m.content)) for (const c of m.content) if (c.type==="tool_use") { + tools[c.name]=(tools[c.name]||0)+1; + if (c.name.startsWith("mcp__")) mcp++; + const cmd = c.input?.command||""; + if (c.name==="Bash") { if (/\.\/check/.test(cmd)) checks++; if (/\.\/test/.test(cmd)) tests++; } + if (c.name==="Read") { reads++; if (/node_modules|guide|spec\//.test(c.input.file_path||"")) readsDocs++; } + } + } + const u0=calls[0]; const fixed = (u0.cache_creation_input_tokens??0)+(u0.cache_read_input_tokens??0)+(u0.input_tokens??0); + const billed = calls.reduce((s,u)=>s+(u.input_tokens??0)*1+(u.cache_creation_input_tokens??0)*1.25+(u.cache_read_input_tokens??0)*0.1+(u.output_tokens??0)*5,0); + const output = calls.reduce((s,u)=>s+(u.output_tokens??0),0); + out.push({run, task:run.split("-")[0], arm:run.split("-")[1], billed, calls:calls.length, output, fixed, checks, tests, reads, readsDocs, mcp, secs:Math.round((t1-t0)/1000), usd: billed*PRICE}); +} +const med=xs=>{const s=[...xs].sort((a,b)=>a-b);const n=s.length;return n%2?s[(n-1)/2]:(s[n/2-1]+s[n/2])/2}; +const mean=xs=>xs.reduce((a,b)=>a+b,0)/xs.length; +const groups={}; +for (const r of out){ for (const k of [`${r.task}/${r.arm}`,`ALL/${r.arm}`]) (groups[k]??=[]).push(r); } +console.log("group n mean median calls out fixed checks tests reads docs mcp secs usd"); +for (const [k,g] of Object.entries(groups).sort()) { + const f=x=>g.map(r=>r[x]); + console.log(k.padEnd(14), String(g.length).padStart(2), String(Math.round(mean(f("billed")))).padStart(7), String(Math.round(med(f("billed")))).padStart(7), + mean(f("calls")).toFixed(1).padStart(5), String(Math.round(mean(f("output")))).padStart(6), String(Math.round(mean(f("fixed")))).padStart(6), + mean(f("checks")).toFixed(1).padStart(5), mean(f("tests")).toFixed(1).padStart(5), mean(f("reads")).toFixed(1).padStart(5), mean(f("readsDocs")).toFixed(1).padStart(4), String(f("mcp").reduce((a,b)=>a+b,0)).padStart(3), String(Math.round(med(f("secs")))).padStart(5), mean(f("usd")).toFixed(2)); +} +writeFileSync(outFile, JSON.stringify(out,null,2)+"\n"); diff --git a/eval/harness/cost-warm.mjs b/eval/harness/cost-warm.mjs new file mode 100644 index 0000000..8d73f2e --- /dev/null +++ b/eval/harness/cost-warm.mjs @@ -0,0 +1,33 @@ +// Billed cost without each run's first model call (round 5b, post hoc). The first call is the same in every run of +// a round (system prompt, tools and the one-line start), but whether its prefix is read from the prompt cache or +// written to it depends on what ran just before, not on the arm: round 5's labels batch and all of 5b started cold +// (prefix written, about 31k billed units more), round 5's related and count batches warm. Dropping it compares the +// runs' own work. Usage: node harness/cost-warm.mjs [out.json] +import { readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { WEIGHTS, permutation } from "./cost.mjs"; + +const [list, dir, out] = process.argv.slice(2); +const bill = u => (u.input_tokens ?? 0) * WEIGHTS.input + (u.cache_creation_input_tokens ?? 0) * WEIGHTS.cacheWrite + + (u.cache_read_input_tokens ?? 0) * WEIGHTS.cacheRead + (u.output_tokens ?? 0) * WEIGHTS.output; +const runs = readFileSync(list, "utf8").trim().split("\n").map(l => l.trim().split(/\s+/)).map(([run, id]) => { + const seen = new Set(); const calls = []; + for (const line of readFileSync(path.join(dir, `agent-${id}.jsonl`), "utf8").split("\n")) { + let e; try { e = JSON.parse(line); } catch { continue; } + if (e.message?.usage && !seen.has(e.message.id)) { seen.add(e.message.id); calls.push(e.message.usage); } + } + const [, task, arm] = run.match(/^(\w+)-(\w+)-(\d+)$/); + return { run, task, arm, first: bill(calls[0]), rest: calls.slice(1).reduce((s, u) => s + bill(u), 0) }; +}); +const mean = xs => xs.reduce((a, b) => a + b, 0) / xs.length; +const arms = [...new Set(runs.map(r => r.arm))]; +for (const a of arms) for (const t of [...new Set(runs.map(r => r.task)), "ALL"]) { + const xs = runs.filter(r => r.arm === a && (t === "ALL" || r.task === t)).map(r => r.rest); + console.log(`${a.padEnd(6)} ${t.padEnd(8)} n ${xs.length} mean ${Math.round(mean(xs))}`); +} +const tests = []; +for (const a of arms) for (const b of arms) if (a !== b) { + const xa = runs.filter(r => r.arm === a).map(r => r.rest), xb = runs.filter(r => r.arm === b).map(r => r.rest); + if (mean(xa) > mean(xb)) { const p = permutation(xa, xb); tests.push({ higher: a, lower: b, diff: mean(xa) - mean(xb), p }); console.log(`${a} - ${b} = ${Math.round(mean(xa) - mean(xb))}, one-sided p = ${p.toFixed(4)}`); } +} +if (out) writeFileSync(out, JSON.stringify({ runs, tests }, null, 2) + "\n"); diff --git a/eval/harness/cost.mjs b/eval/harness/cost.mjs new file mode 100644 index 0000000..164110a --- /dev/null +++ b/eval/harness/cost.mjs @@ -0,0 +1,65 @@ +// Billed cost of agent runs (round 5's primary measure), from the agents' transcripts. +// A run's cost is its tokens weighted by their price relative to an uncached input token: +// input × 1 + cache write × 1.25 + cache read × 0.1 + output × 5 +// summed over the run's model calls (each call counted once, however many transcript lines repeat it). +// The test: one-sided permutation test that React's mean cost is higher than .rx's (100,000 relabellings, seeded). +// Usage: node harness/cost.mjs [out.json] +// agents.txt: one run per line, `-- `; the transcript is /agent-.jsonl +import { readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +export const WEIGHTS = { input: 1, cacheWrite: 1.25, cacheRead: 0.1, output: 5 }; + +export function usage(file) { + const seen = new Set(); + const t = { input: 0, cacheWrite: 0, cacheRead: 0, output: 0, calls: 0 }; + for (const line of readFileSync(file, "utf8").split("\n")) { + if (!line) continue; + let e; try { e = JSON.parse(line); } catch { continue; } + const m = e.message; const u = m?.usage; + if (!u || seen.has(m.id)) continue; + seen.add(m.id); + t.input += u.input_tokens ?? 0; t.cacheWrite += u.cache_creation_input_tokens ?? 0; + t.cacheRead += u.cache_read_input_tokens ?? 0; t.output += u.output_tokens ?? 0; t.calls++; + } + t.billed = Object.entries(WEIGHTS).reduce((s, [k, w]) => s + t[k] * w, 0); + return t; +} + +const mean = xs => xs.reduce((a, b) => a + b, 0) / xs.length; +const sd = xs => Math.sqrt(xs.reduce((a, x) => a + (x - mean(xs)) ** 2, 0) / (xs.length - 1)); + +/** one-sided p that mean(a) - mean(b) is this large by chance */ +export function permutation(a, b, n = 100000, seed = 5) { + let s = seed; const rand = () => ((s = (s * 1103515245 + 12345) % 2147483648) / 2147483648); + const all = [...a, ...b]; const obs = mean(a) - mean(b); let hits = 0; + for (let i = 0; i < n; i++) { + const p = [...all]; + for (let j = p.length - 1; j > 0; j--) { const k = Math.floor(rand() * (j + 1)); [p[j], p[k]] = [p[k], p[j]]; } + if (mean(p.slice(0, a.length)) - mean(p.slice(a.length)) >= obs) hits++; + } + return (hits + 1) / (n + 1); +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + const [list, dir, out] = process.argv.slice(2); + const runs = readFileSync(list, "utf8").trim().split("\n").map(l => l.trim().split(/\s+/)).map(([run, id]) => { + const [, task, arm, n] = run.match(/^(\w+)-(rx|react|solid|foldkit)-(\d+)$/); + return { run, task, arm, n: Number(n), ...usage(path.join(dir, `agent-${id}.jsonl`)) }; + }); + for (const r of runs) console.log(`${r.run.padEnd(16)} billed ${Math.round(r.billed).toString().padStart(7)} calls ${r.calls} out ${r.output} cacheRead ${r.cacheRead} cacheWrite ${r.cacheWrite}`); + const by = arm => runs.filter(r => r.arm === arm).map(r => r.billed); + const react = by("react"), rx = by("rx"), solid = by("solid"); + for (const [arm, xs] of [["react", react], ["rx", rx], ["solid", solid]]) if (xs.length) console.log(`${arm.padEnd(6)} n ${xs.length} mean ${Math.round(mean(xs))} sd ${Math.round(sd(xs))}`); + const summary = { weights: WEIGHTS, runs }; + if (react.length && rx.length) { + summary.diff = mean(react) - mean(rx); summary.ratio = mean(rx) / mean(react); summary.p = permutation(react, rx); + console.log(`React - .rx = ${Math.round(summary.diff)} (.rx/React ${summary.ratio.toFixed(3)}), one-sided permutation p = ${summary.p.toFixed(4)}`); + } + if (solid.length && rx.length) { + summary.solid = { diff: mean(solid) - mean(rx), ratio: mean(rx) / mean(solid), p: permutation(solid, rx) }; + console.log(`Solid - .rx = ${Math.round(summary.solid.diff)} (.rx/Solid ${summary.solid.ratio.toFixed(3)}), one-sided permutation p = ${summary.solid.p.toFixed(4)}`); + } + if (out) writeFileSync(out, JSON.stringify(summary, null, 2) + "\n"); +} diff --git a/eval/harness/eslint-solid.config.mjs b/eval/harness/eslint-solid.config.mjs new file mode 100644 index 0000000..e91674f --- /dev/null +++ b/eval/harness/eslint-solid.config.mjs @@ -0,0 +1,8 @@ +// The Solid arm's lint: eslint-plugin-solid's Solid 2 config. +import tseslint from "typescript-eslint"; +import solid from "eslint-plugin-solid/configs/v2"; + +export default [ + { files: ["src/**/*.{ts,tsx}"], languageOptions: { parser: tseslint.parser, parserOptions: { ecmaFeatures: { jsx: true } } } }, + { files: ["src/**/*.{ts,tsx}"], ...solid }, +]; diff --git a/eval/harness/fuzz-stats.mjs b/eval/harness/fuzz-stats.mjs new file mode 100644 index 0000000..395bfa4 --- /dev/null +++ b/eval/harness/fuzz-stats.mjs @@ -0,0 +1,63 @@ +// Round 8 statistics (PREREG-fuzz-8.md): per-app hole rates from `harness/fuzz.mjs --json` results, H8a (one-sided +// permutation test on mean rates, React vs .rx, 100,000 relabellings, seed 5), H8b (one-sided Fisher's exact test on +// apps with at least one hole), and the tables reported alongside (per arm and task, holes by kind). +// Usage: node harness/fuzz-stats.mjs [--json out.json] +import { readdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +function rng(seed) { let a = seed >>> 0; return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } +const mean = xs => xs.reduce((s, x) => s + x, 0) / xs.length; + +/** P(mean(a) - mean(b) >= observed) under random relabelling */ +function permutation(a, b, n = 100000, seed = 5) { + const r = rng(seed); const all = [...a, ...b]; const obs = mean(a) - mean(b); let hits = 0; + for (let i = 0; i < n; i++) { + const xs = [...all]; + for (let j = xs.length - 1; j > 0; j--) { const k = Math.floor(r() * (j + 1)); [xs[j], xs[k]] = [xs[k], xs[j]]; } + if (mean(xs.slice(0, a.length)) - mean(xs.slice(a.length)) >= obs - 1e-12) hits++; + } + return { observed: obs, p: hits / n }; +} + +const lf = n => { let s = 0; for (let i = 2; i <= n; i++) s += Math.log(i); return s; }; +/** one-sided Fisher: P(first group has >= a successes) given the margins */ +function fisher(a, n1, c, n2) { + const k = a + c; const N = n1 + n2; let p = 0; + for (let x = a; x <= Math.min(k, n1); x++) p += Math.exp(lf(n1) - lf(x) - lf(n1 - x) + lf(n2) - lf(k - x) - lf(n2 - k + x) - lf(N) + lf(k) + lf(N - k)); + return Math.min(1, p); +} + +export function stats(dir) { + const apps = []; + for (const f of readdirSync(dir).filter(f => f.endsWith(".json")).sort()) { + const j = JSON.parse(readFileSync(path.join(dir, f), "utf8")); + const name = path.basename(j.dir); const round = path.basename(path.dirname(j.dir)); + const arm = name.match(/-(react|rx|solid|foldkit)-\d+$/)[1]; const task = name.replace(/-(react|rx|solid|foldkit)-\d+$/, ""); + apps.push({ round, name, arm, task, build: j.build, seeds: j.seeds, holes: j.violations.length, kinds: j.violations.map(v => v.kind), rate: j.build ? j.violations.length / j.seeds : null }); + } + const built = apps.filter(a => a.build); + const react = built.filter(a => a.arm === "react"); const rx = built.filter(a => a.arm === "rx"); + const h8a = permutation(react.map(a => a.rate), rx.map(a => a.rate)); + const reverse = permutation(rx.map(a => a.rate), react.map(a => a.rate)); + const withHole = xs => xs.filter(a => a.holes > 0).length; + const h8b = { react: [withHole(react), react.length], rx: [withHole(rx), rx.length], p: fisher(withHole(react), react.length, withHole(rx), rx.length), reverseP: fisher(withHole(rx), rx.length, withHole(react), react.length) }; + const groups = {}; + for (const a of built) { const k = `${a.round} ${a.task} ${a.arm}`; (groups[k] ??= []).push(a.holes); } + const kinds = {}; + for (const a of built) for (const k of a.kinds) { kinds[a.arm] ??= {}; kinds[a.arm][k] = (kinds[a.arm][k] ?? 0) + 1; } + const perArm = {}; + for (const arm of ["react", "rx", "solid", "foldkit"]) { const xs = built.filter(a => a.arm === arm); if (xs.length) perArm[arm] = { apps: xs.length, meanRate: mean(xs.map(a => a.rate)), withHole: withHole(xs), seedsWithHole: xs.reduce((s, a) => s + a.holes, 0), seeds: xs.reduce((s, a) => s + a.seeds, 0) }; } + return { apps, unbuilt: apps.filter(a => !a.build).map(a => `${a.round}/${a.name}`), h8a: { reactMean: mean(react.map(a => a.rate)), rxMean: mean(rx.map(a => a.rate)), ...h8a, reverseP: reverse.p }, h8b, perArm, groups, kinds }; +} + +if (process.argv[1] && import.meta.url.endsWith(path.basename(process.argv[1]))) { + const a = process.argv.slice(2); const s = stats(path.resolve(a[0])); + const pct = x => `${(100 * x).toFixed(1)}%`; + console.log(`apps: ${s.apps.length} (${s.unbuilt.length} did not build: ${s.unbuilt.join(", ") || "none"})`); + console.log(`H8a: React mean hole rate ${pct(s.h8a.reactMean)} vs .rx ${pct(s.h8a.rxMean)}; one-sided p (React > .rx) = ${s.h8a.p.toFixed(4)}; reverse p = ${s.h8a.reverseP.toFixed(4)}`); + console.log(`H8b: apps with a hole React ${s.h8b.react.join("/")} vs .rx ${s.h8b.rx.join("/")}; Fisher one-sided p = ${s.h8b.p.toFixed(4)}; reverse p = ${s.h8b.reverseP.toFixed(4)}`); + console.log("\nper arm:"); for (const [k, v] of Object.entries(s.perArm)) console.log(` ${k.padEnd(8)} ${v.apps} apps, mean rate ${pct(v.meanRate)}, ${v.withHole} with a hole, ${v.seedsWithHole}/${v.seeds} seeds`); + console.log("\nholes per app (of 50 seeds), by round, task and arm:"); for (const [k, v] of Object.entries(s.groups)) console.log(` ${k.padEnd(32)} ${v.join(" ")}`); + console.log("\nholes by kind:"); for (const [arm, ks] of Object.entries(s.kinds)) console.log(` ${arm}: ${Object.entries(ks).sort((x, y) => y[1] - x[1]).map(([k, n]) => `${k} ${n}`).join(", ")}`); + const o = a.indexOf("--json"); if (o >= 0) writeFileSync(a[o + 1], JSON.stringify(s, null, 2) + "\n"); +} diff --git a/eval/harness/fuzz.mjs b/eval/harness/fuzz.mjs new file mode 100644 index 0000000..3079ea4 --- /dev/null +++ b/eval/harness/fuzz.mjs @@ -0,0 +1,294 @@ +// Round 8: an async timing fuzzer for the issue desk (rounds 5-7 apps). No per-app tests: a seeded random session of +// user moves and backend answers (land or fail, in any order, after the page was left), against a fake server that +// keeps the truth, checked by two oracles every desk must pass whatever the agent built: +// - every frame (after each step, once the page settles): each issue row and the detail heading show the server's +// value or the value of a write still in flight; no row the server has closed shows in the open list; no open +// row is missing unless a close of it is in flight; the detail shows the selected issue. +// - at rest (nothing in flight, at the end of a session): the issues page matches the server: the rows, assignees, +// titles, the tab count, and the selected issue's heading, labels and comments. +// Allowances, the same for every arm (FUZZ-A1..A3 in PREREG-fuzz-8.md): +// - A1 (one change, H-11): a declined write's value, or a declined close's missing row, may show while any other +// write is in flight. +// - A2 (newest wins): with several writes to one field accepted in one busy period, at rest the field may show the +// newest one started instead of the one the server took last. +// - A3: the count is checked only at rest, on the issues page, with the open list on screen. +// Usage: node harness/fuzz.mjs [--seeds N] [--steps N] [--from S] [--json out.json] [--log] +import { writeFileSync } from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { build } from "./build.mjs"; +import { launch, serve } from "./driver.mjs"; + +const norm = s => (s ?? "").replace(/\s+/g, " ").trim(); +function rng(seed) { let a = seed >>> 0; return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } + +const USERS = ["ann", "bob", "guest"]; +const TITLES = ["Alpha", "Bravo", "Delta"]; +const LABELS = ["bug", "ui", "p1"]; +const TEXTS = ["me too", "fixed?", "ping"]; + +export function freshServer() { + return { + issues: [ + { id: 1, title: "Login fails", assignee: null, open: true, body: "on Safari only" }, + { id: 2, title: "Old bug", assignee: "eve", open: false, body: "long gone" }, + { id: 3, title: "Crash on save", assignee: null, open: true, body: "stack trace attached" }, + { id: 4, title: "Typo in footer", assignee: "dana", open: true, body: "footer says 2019" }, + ], + comments: { 1: [], 2: [], 3: [{ author: "dana", text: "seen it too" }], 4: [] }, + labels: { 1: [], 2: ["old"], 3: ["bug", "ui"], 4: ["docs"] }, + }; +} +const row = i => ({ id: i.id, title: i.title, assignee: i.assignee }); +const WRITES = new Set(["assign", "renameIssue", "closeIssue", "addComment", "addLabel", "removeLabel", "saveSettings"]); + +/** the answer the server gives `key` now, applying a write; `fail` = the server declines / the read fails */ +export function answer(srv, key, fail) { + const i = key.indexOf(":"); const name = key.slice(0, i); const args = key.slice(i + 1).split(","); + const issue = id => srv.issues.find(x => x.id === Number(id)); + if (fail) return { fail: true }; + switch (name) { + case "fetchIssues": return { value: srv.issues.filter(x => x.open === (args[0] === "open")).map(row) }; + case "fetchIssue": { const x = issue(args[0]); return { value: x ? { id: x.id, title: x.title, body: x.body } : null }; } + case "assign": { const x = issue(args[0]); x.assignee = args[1]; return { value: { id: x.id, assignee: x.assignee } }; } + case "renameIssue": { const x = issue(args[0]); x.title = args.slice(1).join(","); return { value: { id: x.id, title: x.title } }; } + case "closeIssue": { const x = issue(args[0]); x.open = false; return { value: { id: x.id } }; } + case "fetchComments": return { value: srv.comments[args[0]] ?? [] }; + case "addComment": { const c = { author: args[1], text: args.slice(2).join(",") }; srv.comments[args[0]] = [...(srv.comments[args[0]] ?? []), c]; return { value: c }; } + case "fetchLabels": return { value: srv.labels[args[0]] ?? [] }; + case "addLabel": { const l = srv.labels[args[0]] ?? []; if (!l.includes(args[1])) srv.labels[args[0]] = [...l, args[1]]; return { value: srv.labels[args[0]] }; } + case "removeLabel": { srv.labels[args[0]] = (srv.labels[args[0]] ?? []).filter(l => l !== args[1]); return { value: srv.labels[args[0]] }; } + case "search": return { value: args.join(",").length >= 2 ? [{ id: 7, name: "saving files" }, { id: 8, name: "crash reports" }] : [] }; + case "fetchItem": return { value: { id: Number(args[0]), name: `item ${args[0]}`, blurb: "about it" } }; + case "loadSettings": return { value: { name: "Ann", theme: "light", density: "compact" } }; + default: return { value: null }; + } +} + +/** one session; returns the violations it found (each with the step and the log so far) */ +async function session(browser, url, seed, steps, log) { + const r = rng(seed); const pick = xs => xs[Math.floor(r() * xs.length)]; + const page = await browser.newPage(); + const errors = []; page.on("pageerror", e => errors.push(e.message)); + const settle = () => page.waitForTimeout(60); + const sel = id => `[data-testid="${id}"]`; + const srv = freshServer(); + const ui = { page: "issues", filter: "open", selected: null }; + // writes in flight (key -> { issue, field, value }); declined values per issue/field during the busy period; + // accepted writes per field in this busy period, in start order + const writes = new Map(); let declined = []; let started = []; let seq = 0; let gen = 0; let stale = []; let held = { filters: new Set(), selections: new Set(), rows: new Set() }; const settledCount = new Map(); + const trail = []; const found = []; + const pending = () => page.evaluate(() => globalThis.__flights.pending()); + const texts = async id => (await page.locator(sel(id)).allTextContents()).map(norm); + const exists = async id => (await page.locator(sel(id)).count()) > 0; + const text = async id => (await exists(id)) ? norm(await page.locator(sel(id)).first().textContent()) : null; + const note = (kind, detail) => { found.push({ seed, step: trail.length, kind, detail, trail: [...trail] }); }; + const writeOf = key => { + const [name, rest] = [key.slice(0, key.indexOf(":")), key.slice(key.indexOf(":") + 1)]; const a = rest.split(","); + if (name === "assign") return { issue: Number(a[0]), field: "assignee", value: a[1] }; + if (name === "renameIssue") return { issue: Number(a[0]), field: "title", value: a.slice(1).join(",") }; + if (name === "closeIssue") return { issue: Number(a[0]), field: "open", value: false }; + return { issue: Number(a[0]), field: name, value: a.slice(1).join(",") }; + }; + // a write asked again while its first ask is in the air (one key to the test) is started again: it moves to the newest + const askCount = new Map(); + const track = async () => { + const asked = await page.evaluate(() => globalThis.__flights.asked()); const count = k => asked.filter(x => x === k).length; + for (const k of await pending()) { + if (!WRITES.has(k.slice(0, k.indexOf(":")))) continue; + if (!writes.has(k)) { if (!writes.size) started = []; const w = { ...writeOf(k), seq: seq++, gen }; writes.set(k, w); started.push(w); } + else if (count(k) > askCount.get(k)) { const w = writes.get(k); started.splice(started.indexOf(w), 1); w.seq = seq++; started.push(w); } + askCount.set(k, count(k)); + } + }; + const busy = () => writes.size > 0; + let inFlight = false; // anything in the air at this frame (A1: an action runs until its refresh answers) + const allowed = (id, field) => { + const x = srv.issues.find(i => i.id === id); const vals = new Set([x[field]]); + for (const w of writes.values()) if (w.issue === id && w.field === field) vals.add(w.value); + if (inFlight) for (const d of declined) if (d.issue === id && d.field === field) vals.add(d.value); // A1 + for (const t of stale) if (t.issue === id && t.field === field) vals.add(t.before); // A4 + for (const w of started) if (w.accepted && w.issue === id && w.field === field && (busy() || w === started.filter(s => s.accepted && s.issue === id && s.field === field).at(-1))) vals.add(w.value); // A2 + return vals; + }; + + function listViolations(parsed, filter, valuesToo = true) { + const out = []; + for (const p of parsed) { + const x = srv.issues.find(i => i.id === p.id); + if (!x) { out.push(["unknown row", `#${p.id}`]); continue; } + if (valuesToo && !allowed(p.id, "title").has(p.title)) out.push(["row title", `#${p.id} shows "${p.title}", server "${x.title}"`]); + if (valuesToo && !allowed(p.id, "assignee").has(p.assignee)) out.push(["row assignee", `#${p.id} shows ${p.assignee}, server ${x.assignee}`]); + if (filter === "open" && !x.open && ![...writes.values()].some(w => w.issue === p.id && w.field === "open") && !stale.some(t => t.issue === p.id && t.field === "open")) { + const lingering = inFlight && declined.some(d => d.issue === p.id && d.field === "open"); + if (!lingering) out.push(["closed row shown", `#${p.id} is closed on the server`]); + } + if (filter === "closed" && x.open) out.push(["open row in closed list", `#${p.id}`]); + } + if (filter === "open") for (const x of srv.issues.filter(i => i.open)) { + if (parsed.some(p => p.id === x.id)) continue; + const closing = [...writes.values()].some(w => w.issue === x.id && w.field === "open"); + const lingering = inFlight && declined.some(d => d.issue === x.id && d.field === "open"); // A1 + if (!closing && !lingering) out.push(["open row missing", `#${x.id}`]); + } + return out; + } + + async function everyFrame() { + inFlight = (await pending()).length > 0; + if (ui.page !== "issues") return; + const rows = await texts("issue"); + const listText = await text("list"); + const parsed = rows.map(t => t.match(/^#(\d+) (.*) \((.*)\)$/)).filter(Boolean).map(m => ({ id: Number(m[1]), title: m[2], assignee: m[3] === "unassigned" ? null : m[3] })); + if (parsed.length && listText && !/loading/.test(listText)) { + const v = listViolations(parsed, ui.filter); + // A5: a filter switch made while work is in flight may hold the previous filter's list (Ryan, 2026-10-28) + if (v.length && !(held.filters.size && parsed.every(p => held.rows.has(p.id))) && ![...held.filters].some(f => !listViolations(parsed, f, false).length)) for (const [k, d] of v) note(k, d); + } + const detail = await text("detail"); + if (detail && ui.selected != null && !/loading #|detail error|select an issue/.test(detail)) { + const m = detail.match(/#(\d+) /); + const shownId = m ? Number(m[1]) : null; + if (m && shownId !== ui.selected && !held.selections.has(shownId)) note("wrong detail", `selected #${ui.selected}, detail shows #${m[1]}`); // A5 + else if (m && ![...allowed(shownId, "title")].some(t => detail.includes(`#${shownId} ${t}`))) note("detail title", `"${detail.slice(0, 60)}", server "${srv.issues.find(i => i.id === shownId).title}"`); + } + } + + async function atRest() { + if (ui.page !== "issues") return; + const listText = await text("list"); + if (!listText || /error/.test(listText)) return; + const parsed = (await texts("issue")).map(t => t.match(/^#(\d+) (.*) \((.*)\)$/)).filter(Boolean).map(m => ({ id: Number(m[1]), title: m[2], assignee: m[3] === "unassigned" ? null : m[3] })); + const want = srv.issues.filter(i => i.open === (ui.filter === "open") || (ui.filter === "open" && stale.some(t => t.issue === i.id && t.field === "open") && parsed.some(p => p.id === i.id))); + const newest = (id, field) => { const ws = started.filter(w => w.issue === id && w.field === field && w.accepted); return ws.length ? ws.at(-1).value : undefined; }; + const ok = (id, field, v) => { const x = srv.issues.find(i => i.id === id); return v === x[field] || v === newest(id, field) || stale.some(t => t.issue === id && t.field === field && t.before === v); }; // A2, A4 + const ids = parsed.map(p => p.id).join(","); + // the closed list need not gain an issue closed after it loaded (no brief asks for it): it must hold no open issue + const closedOk = ui.filter === "closed" && parsed.every(p => want.some(i => i.id === p.id)); + if (ids !== want.map(i => i.id).join(",") && !closedOk) note("at rest: rows", `shows [${ids}], server [${want.map(i => i.id)}]`); + for (const p of parsed) { + if (!want.some(i => i.id === p.id)) continue; + if (!ok(p.id, "title", p.title)) note("at rest: row title", `#${p.id} "${p.title}"`); + if (!ok(p.id, "assignee", p.assignee)) note("at rest: row assignee", `#${p.id} ${p.assignee}`); + } + const tab = await text("tab-issues"); + const cm = tab && tab.match(/\((\d+)\)/); + if (cm && ui.filter === "open" && Number(cm[1]) !== parsed.length) note("at rest: count", `tab ${tab}, ${parsed.length} open rows shown`); // A3 + if (ui.selected != null) { + const detail = await text("detail"); + const x = srv.issues.find(i => i.id === ui.selected); + if (detail && !/detail error/.test(detail) && !detail.includes(`#${x.id} ${x.title}`) && !(newest(x.id, "title") && detail.includes(`#${x.id} ${newest(x.id, "title")}`))) note("at rest: detail", `"${detail.slice(0, 60)}", server "#${x.id} ${x.title}"`); + if (await exists("label")) { + const labelsText = await text("labels"); + if (labelsText && !/error/.test(labelsText)) { const ls = (await texts("label")).join(","); if (ls !== (srv.labels[x.id] ?? []).join(",")) note("at rest: labels", `#${x.id} shows [${ls}], server [${srv.labels[x.id]}]`); } + } + const commentsText = await text("comments"); + if (commentsText && !/error|loading/.test(commentsText)) { + const cs = (await texts("comment")).join(" | "); const want2 = (srv.comments[x.id] ?? []).map(c => `${c.author}: ${c.text}`).join(" | "); + if (cs !== want2) note("at rest: comments", `#${x.id} shows [${cs}], server [${want2}]`); + } + } + } + + async function answerOne(keys) { + const key = pick(keys); const name = key.slice(0, key.indexOf(":")); + const fail = r() < (WRITES.has(name) ? 0.3 : 0.1); + const w = writes.get(key); + const target = w && srv.issues.find(i => i.id === w.issue); + const before = target && w.field in target ? target[w.field] : undefined; + // two calls with one key while both are in the air are one flight to the test, but two requests to the server + const times = Math.max(1, (await page.evaluate(k => globalThis.__flights.asked().filter(x => x === k).length, key)) - (settledCount.get(key) ?? 0)); + settledCount.set(key, (settledCount.get(key) ?? 0) + times); + let a; for (let t = 0; t < times; t++) a = answer(srv, key, fail); + if (w && !fail && w.gen !== gen && target) stale.push({ issue: w.issue, field: w.field, before }); // A4 + trail.push(`${fail ? "fail" : "land"} ${key}`); + if (w) { writes.delete(key); if (fail) declined.push(w); else w.accepted = true; } + await page.evaluate(([k, a]) => a.fail ? globalThis.__flights.fail(k, "declined") : globalThis.__flights.land(k, a.value), [key, a]); + await settle(); + if (!(await pending()).length) declined = []; + if (!(await pending()).length) held = { filters: new Set(), selections: new Set(), rows: new Set() }; + } + + async function move() { + const opts = []; + const rows = await texts("issue"); + if (ui.page === "issues") { + if (rows.length) opts.push(["select", 3]); + if (await exists("assign")) opts.push(["assign", 2]); + if (await exists("rename")) opts.push(["rename", 2]); + if (await exists("close")) opts.push(["close", 2]); + if (await exists("row-assign")) opts.push(["row-assign", 2]); + if (await exists("row-close")) opts.push(["row-close", 2]); + if (await exists("add-label")) opts.push(["add-label", 1]); + if (await exists("remove-label")) opts.push(["remove-label", 1]); + if (await exists("post")) opts.push(["post", 1]); + opts.push(["user", 1], ["filter", 1], ["leave", 1]); + } else opts.push(["back", 3]); + const total = opts.reduce((s, o) => s + o[1], 0); let x = r() * total; let m = opts[0][0]; + for (const [k, w] of opts) { if ((x -= w) < 0) { m = k; break; } } + const click = async (id, nth = 0) => { await page.locator(sel(id)).nth(nth).click({ timeout: 2000 }); }; + switch (m) { + case "select": { const t = pick(rows); const id = Number(t.match(/^#(\d+)/)?.[1]); if (!id) return; trail.push(`select #${id}`); await page.locator(sel("issue"), { hasText: `#${id} ` }).first().click({ timeout: 2000 }); if ((await pending()).length && ui.selected !== id) held.selections.add(ui.selected); ui.selected = id; break; } + case "user": { const u = pick(USERS); trail.push(`user ${u}`); await page.locator(sel("user")).first().fill(u); break; } + case "assign": trail.push("assign"); await click("assign"); break; + case "rename": { const t = pick(TITLES); trail.push(`rename ${t}`); await page.locator(sel("title-input")).first().fill(t); await click("rename"); break; } + case "close": trail.push("close"); await click("close"); break; + case "row-assign": { const n = Math.floor(r() * (await page.locator(sel("row-assign")).count())); trail.push(`row-assign ${n}`); await click("row-assign", n); break; } + case "row-close": { const n = Math.floor(r() * (await page.locator(sel("row-close")).count())); trail.push(`row-close ${n}`); await click("row-close", n); break; } + case "add-label": { const l = pick(LABELS); trail.push(`add-label ${l}`); await page.locator(sel("label-input")).first().fill(l); await click("add-label"); break; } + case "remove-label": { const n = Math.floor(r() * (await page.locator(sel("remove-label")).count())); trail.push(`remove-label ${n}`); await click("remove-label", n); break; } + case "post": { const t = pick(TEXTS); trail.push(`post ${t}`); await page.locator(sel("comment-input")).first().fill(t); await click("post"); break; } + case "filter": { const f = ui.filter === "open" ? "closed" : "open"; trail.push(`filter ${f}`); if ((await pending()).length) { held.filters.add(ui.filter); for (const t of await texts("issue")) { const m = t.match(/^#(\d+)/); if (m) held.rows.add(Number(m[1])); } } await click(`filter-${f}`); ui.filter = f; break; } + case "leave": trail.push("tab help"); await click("tab-help"); ui.page = "help"; gen++; break; + case "back": trail.push("tab issues"); await click("tab-issues"); ui.page = "issues"; ui.filter = "open"; ui.selected = null; gen++; stale = []; held = { filters: new Set(), selections: new Set(), rows: new Set() }; break; + } + await settle(); + } + + try { + await page.goto(url); await page.waitForFunction(() => globalThis.__flights !== undefined, null, { timeout: 5000 }).catch(() => {}); + await settle(); + for (let s = 0; s < steps; s++) { + await track(); + const keys = await pending(); + if (keys.length && r() < 0.5) await answerOne(keys); else await move().catch(e => { trail.push(`(move failed: ${e.message.split("\n")[0].slice(0, 80)})`); }); + await track(); + await everyFrame(); + if (found.length) break; + } + for (let guard = 0; !found.length && guard < 200; guard++) { + await track(); + const keys = await pending(); + if (!keys.length) break; + await answerOne(keys); await track(); await everyFrame(); + } + if (!found.length && !(await pending()).length) await atRest(); + if (errors.length && !found.length) note("page error", errors[0].slice(0, 120)); + } finally { await page.close(); } + if (log) console.log(`seed ${seed}: ${trail.join(" ; ")}`); + return found.slice(0, 1); +} + +export async function fuzz(dir, { seeds = 40, steps = 30, from = 1, log = false } = {}) { + const b = await build(dir); + if (!b.ok) return { dir, build: false, seeds, violations: [] }; + const server = await serve(b.dist); let browser = await launch(); + const violations = []; + try { + for (let s = from; s < from + seeds; s++) { + // a page that crashes or hangs the browser is a hole of its own; the next seed gets a fresh browser + try { let t; violations.push(...await Promise.race([session(browser, server.url, s, steps, log), new Promise((_, rej) => { t = setTimeout(() => rej(new Error("session timed out (120 s): the page hangs")), 120000); })]).finally(() => clearTimeout(t))); } + catch (e) { violations.push({ seed: s, step: -1, kind: "page crash", detail: e.message.split("\n")[0].slice(0, 120), trail: [] }); await browser.close().catch(() => {}); browser = await launch(); } + } + } + finally { await browser.close(); server.close(); } + return { dir, build: true, seeds, violations }; +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + const a = process.argv.slice(2); const opt = (k, d) => { const i = a.indexOf(k); return i >= 0 ? a[i + 1] : d; }; + const res = await fuzz(path.resolve(a[0]), { seeds: Number(opt("--seeds", 40)), steps: Number(opt("--steps", 30)), from: Number(opt("--from", 1)), log: a.includes("--log") }); + console.log(`${path.basename(res.dir)}: ${res.build ? `${res.violations.length}/${res.seeds} seeds found a hole` : "build failed"}`); + for (const v of res.violations) console.log(` seed ${v.seed} step ${v.step}: ${v.kind}: ${v.detail}\n ${v.trail.join(" ; ")}`); + if (opt("--json")) writeFileSync(opt("--json"), JSON.stringify(res, null, 2) + "\n"); +} diff --git a/eval/harness/make-chain.mjs b/eval/harness/make-chain.mjs new file mode 100644 index 0000000..e8d5c95 --- /dev/null +++ b/eval/harness/make-chain.mjs @@ -0,0 +1,56 @@ +// Round 7 (maintenance chains): writes tasks/chain1 … chain4, one task per step of a chain of changes to the issue +// desk, each made by a fresh agent to the app the step before it left. Step k's task: +// - brief-plain.md: the step's change brief; +// - BASE.md: the desk's brief and the earlier steps' change briefs, in order (what the app was built to); +// - api.*: the labels task's API (a superset of every step's); +// - visible.mjs: every step's visible tests so far, in one file, deduplicated by name (the app's test suite grows); +// - hidden.mjs, hidden-overlap.mjs: every step's hidden tests so far, deduplicated by name (earlier steps' hidden +// tests become regressions). +// Usage: node harness/make-chain.mjs +import { cpSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const tasks = path.resolve(here, "../tasks"); +const steps = ["labels", "count", "related", "rows"]; +const read = (t, f) => readFileSync(path.join(tasks, t, f), "utf8"); + +// the fixtures each step's tests need: labels' (count adds none), then related help's, then the row actions' +const union = read("rows", "fixtures.mjs"); +const fixturesFor = k => k <= 2 ? read("labels", "fixtures.mjs") : k === 3 ? union.slice(0, union.indexOf("\n// Row actions")) + "\n" : union; + +/** the entries of a visible file's `export default [ … ]`, as [name, text] */ +function entries(text) { + const body = text.slice(text.indexOf("export default [\n") + "export default [\n".length, text.lastIndexOf("];")); + const parts = body.split(/^(?= \{ name: )/m).filter(p => p.trim()); + return parts.map(p => { + const m = p.match(/^ \{ name: "((?:[^"\\]|\\.)*)"/); + if (!m) throw new Error(`cannot read a test entry: ${p.slice(0, 80)}`); + return [m[1], p]; + }); +} + +const demote = brief => brief.replace(/^(#+) /gm, "#$1 "); +const chained = brief => brief.replace("`BASE.md` is the brief it was built to.", "`BASE.md` is the brief it was built to, with the changes made since."); + +for (let k = 1; k <= steps.length; k++) { + const dir = path.join(tasks, `chain${k}`); + mkdirSync(dir, { recursive: true }); + for (const f of ["api.js", "api.d.ts", "api.d.rx"]) cpSync(path.join(tasks, "labels", f), path.join(dir, f)); + writeFileSync(path.join(dir, "brief-plain.md"), k === 1 ? read(steps[0], "brief-plain.md") : chained(read(steps[k - 1], "brief-plain.md"))); + const earlier = steps.slice(0, k - 1).map((s, i) => `\n---\n\n${demote(read(s, "brief-plain.md")).replace(/^## Change request: /m, `## Change ${i + 1}: `)}`); + writeFileSync(path.join(dir, "BASE.md"), (k === 1 ? "" : `The app was built to the brief below, then changed by the ${k - 1 === 1 ? "change request" : `${k - 1} change requests`} after it, in order.\n\n`) + read("desk", "brief-plain.md") + earlier.join("")); + const fixtures = fixturesFor(k); + writeFileSync(path.join(dir, "fixtures.mjs"), fixtures); + const names = Object.keys(await import(pathToFileURL(path.join(dir, "fixtures.mjs")).href + `?k=${k}`)); + const seen = new Set(); + const tests = []; + for (const s of steps.slice(0, k)) for (const [name, text] of entries(read(s, "visible.mjs"))) if (!seen.has(name)) { seen.add(name); tests.push(text); } + writeFileSync(path.join(dir, "visible.mjs"), `// Visible tests: the agent sees and runs these. The desk's, then each change's so far.\nimport { ${names.join(", ")} } from "./fixtures.mjs";\n\nexport default [\n${tests.join("")}];\n`); + for (const f of ["hidden", "hidden-overlap"]) { + const imports = steps.slice(0, k).map((s, i) => `import s${i} from "../${s}/${f}.mjs";`).join("\n"); + writeFileSync(path.join(dir, `${f}.mjs`), `// Round 7, chain step ${k}: every step's ${f} tests so far, deduplicated by name. Never shown to the agent.\n${imports}\n\nconst seen = new Set();\nexport default [${steps.slice(0, k).map((_, i) => `...s${i}`).join(", ")}].filter(t => !seen.has(t.name) && seen.add(t.name));\n`); + } + console.log(`chain${k}: ${tests.length} visible tests`); +} diff --git a/eval/harness/prompt-arms.json b/eval/harness/prompt-arms.json index 919de3c..ce6c00f 100644 --- a/eval/harness/prompt-arms.json +++ b/eval/harness/prompt-arms.json @@ -10,5 +10,35 @@ "LANG_RULE": "ReactiveScript (`.rx`), a new language that compiles to Solid 2.0. Nothing about it is online. Read `docs/guide/async.md` first: one page on reading from and writing to a server. Its specification is in `docs/spec/` (the concrete syntax is `docs/spec/07-surface.md`; modules and imports are `docs/spec/09-modules.md`). `src/main.rx` must be the program's entry, `export default component App() { … }`; you may add `.rx` modules under `src/`. Import the backend from \"./api\": loaders with `import pure { … } from \"./api\"`, functions that write with `import { … } from \"./api\"`. The prelude gives `import pure { len, str, map, filter, sum, every, indexOf } from \"./prelude\"`. Give an element a test id with `data-testid=\"…\"`.", "API_TYPES": "`src/api.d.rx`", "CHECKER": "the ReactiveScript compiler's checker (`rxc check src/main.rx`)" + }, + "rx-inline": { + "DOCS_RULE": ", except that you may read the language documentation in {{DIR}}/docs", + "LANG_RULE": "ReactiveScript (`.rx`), a new language that compiles to Solid 2.0. Nothing about it is online. The two pages at the end of this message are the language and how it talks to a server; they are enough to write the change. The full specification is in `docs/spec/` if you need a detail they don't cover. `src/main.rx` must be the program's entry, `export default component App() { … }`; you may add `.rx` modules under `src/`. Import the backend from \"./api\": loaders with `import pure { … } from \"./api\"`, functions that write with `import { … } from \"./api\"`. Give an element a test id with `data-testid=\"…\"`.", + "API_TYPES": "`src/api.d.rx`", + "CHECKER": "the ReactiveScript compiler's checker (`rxc check src/main.rx`)" + }, + "solid": { + "DOCS_RULE": ", except that you may read Solid's own documentation in {{DIR}}/node_modules/solid-js (`CHEATSHEET.md`, `skills/`)", + "LANG_RULE": "Solid 2.0 (release candidate) with TypeScript: `src/App.tsx` must default-export the root component (it is mounted with `render` from `@solidjs/web`). You may add files under `src/`. Installed packages you may use: solid-js, @solidjs/web. Solid 2.0 is newer than most training data and differs from Solid 1.x and from React; its own documentation is in `node_modules/solid-js/CHEATSHEET.md` and `node_modules/solid-js/skills/`.", + "API_TYPES": "`src/api.d.ts`", + "CHECKER": "the TypeScript compiler (strict) and ESLint with eslint-plugin-solid's Solid 2 rules (`v2` config)" + }, + "solid-inline": { + "DOCS_RULE": ", except that you may read Solid's own documentation in {{DIR}}/node_modules/solid-js (`CHEATSHEET.md`, `skills/`)", + "LANG_RULE": "Solid 2.0 (release candidate) with TypeScript: `src/App.tsx` must default-export the root component (it is mounted with `render` from `@solidjs/web`). You may add files under `src/`. Installed packages you may use: solid-js, @solidjs/web. Solid 2.0 is newer than most training data and differs from Solid 1.x and from React. The page at the end of this message is Solid's own primer for coding agents; the full API cheatsheet is `node_modules/solid-js/CHEATSHEET.md`.", + "API_TYPES": "`src/api.d.ts`", + "CHECKER": "the TypeScript compiler (strict) and ESLint with eslint-plugin-solid's Solid 2 rules (`v2` config)" + }, + "foldkit": { + "DOCS_RULE": ", except that you may read Foldkit's repository, vendored at the installed release in {{DIR}}/docs/foldkit (examples, skills, the framework source), and the packages in {{DIR}}/node_modules", + "LANG_RULE": "Foldkit 0.170 (The Elm Architecture in TypeScript, on Effect 4): `src/main.ts` must export `Model`, `init`, `update` and `view`; the harness boots them with `Runtime.makeApplication` and `Runtime.run` into a `#app` container, as Foldkit's `src/entry.ts` does. You may add files under `src/`. Installed packages you may use: foldkit, effect, @effect/platform-browser. Foldkit is newer than most training data. Foldkit's own conventions file for coding agents is `docs/foldkit/packages/create-foldkit-app/templates/base/FOLDKIT.md`.", + "API_TYPES": "`src/api.d.ts`", + "CHECKER": "the TypeScript compiler (strict, with Foldkit's template settings)" + }, + "foldkit-inline": { + "DOCS_RULE": ", except that you may read Foldkit's repository, vendored at the installed release in {{DIR}}/docs/foldkit (examples, skills, the framework source), and the packages in {{DIR}}/node_modules", + "LANG_RULE": "Foldkit 0.170 (The Elm Architecture in TypeScript, on Effect 4): `src/main.ts` must export `Model`, `init`, `update` and `view`; the harness boots them with `Runtime.makeApplication` and `Runtime.run` into a `#app` container, as Foldkit's `src/entry.ts` does. You may add files under `src/`. Installed packages you may use: foldkit, effect, @effect/platform-browser. Foldkit is newer than most training data. The page at the end of this message is Foldkit's own conventions file for coding agents (`FOLDKIT.md`); the repository it refers to as `repos/foldkit` is `docs/foldkit` here.", + "API_TYPES": "`src/api.d.ts`", + "CHECKER": "the TypeScript compiler (strict, with Foldkit's template settings)" } } diff --git a/eval/harness/prompt-change.md b/eval/harness/prompt-change.md index 11ef703..15e713e 100644 --- a/eval/harness/prompt-change.md +++ b/eval/harness/prompt-change.md @@ -15,3 +15,5 @@ Two commands, run from {{DIR}}: You may run `./check` and `./test` at most 15 times in total. When you believe the app does everything both briefs say, stop and reply with two or three sentences on what you changed and anything you were unsure about. +Use only file, search and shell tools inside that directory: no messaging, chat, document or GitHub tools. Your +report goes in your final reply and nowhere else. diff --git a/eval/harness/prompt.md b/eval/harness/prompt.md index 3038954..cd2877f 100644 --- a/eval/harness/prompt.md +++ b/eval/harness/prompt.md @@ -13,3 +13,5 @@ Two commands, run from {{DIR}}: You may run `./check` and `./test` at most 15 times in total. When you believe the app does everything the brief says, stop and reply with two or three sentences on what you built and anything you were unsure about. +Use only file, search and shell tools inside that directory: no messaging, chat, document or GitHub tools. Your +report goes in your final reply and nowhere else. diff --git a/eval/harness/replay.mjs b/eval/harness/replay.mjs new file mode 100644 index 0000000..84ba7b0 --- /dev/null +++ b/eval/harness/replay.mjs @@ -0,0 +1,26 @@ +// Round 8: replay one fuzz trail ("step ; step ; …", as `fuzz.mjs` prints it) against an app and print, after each step, +// the rows, the start of the detail and the requests in the air. DUMP=1 also prints the issues store (when the page +// exposes it as globalThis.__stores); DIST= serves a prebuilt dist instead of building . +// Usage: node harness/replay.mjs "" +import { build } from "./build.mjs"; import { launch, serve, runTest } from "./driver.mjs"; import { freshServer, answer } from "./fuzz.mjs"; +const [dir, trail] = process.argv.slice(2); +const b = process.env.DIST ? { dist: process.env.DIST } : await build(dir); const srv = await serve(b.dist); const br = await launch(); const db = freshServer(); +const r = await runTest(br, srv.url, { run: async app => { + await app.page.waitForFunction(() => globalThis.__flights !== undefined); + for (const s of trail.split(" ; ")) { + const [op, ...rest] = s.split(" "); const arg = rest.join(" "); + if (op === "land" || op === "fail") { const a = answer(db, arg, op === "fail"); if (a.fail) await app.fail(arg, "declined"); else await app.land(arg, a.value); } + else if (op === "select") await app.clickText("issue", `${arg} `); + else if (op === "user") await app.type("user", arg); + else if (op === "rename") { await app.type("title-input", arg); await app.click("rename"); } + else if (op === "post") { await app.type("comment-input", arg); await app.click("post"); } + else if (op === "add-label") { await app.type("label-input", arg); await app.click("add-label"); } + else if (op === "filter") await app.click(`filter-${arg}`); + else if (op === "tab") await app.click(`tab-${arg}`); + else if (["row-close", "row-assign", "remove-label"].includes(op)) await app.click(op, Number(arg)); + else await app.click(op); + if (process.env.DUMP) console.log(" store:", await app.page.evaluate(() => { const s = globalThis.__stores?.issues; if (!s) return ""; return JSON.stringify([...s].map(x => ({ keys: Object.keys(x), id: x.id, t: x.title, a: x.assignee }))); })); + console.log(s.slice(0, 32).padEnd(32), "|", JSON.stringify(await app.items("issue")), "|", (await app.text("detail") ?? "").slice(0, 30), "| air", JSON.stringify(await app.pending())); + } +} }); +console.log(r.ok ? "ok" : r.error); await br.close(); srv.close(); diff --git a/eval/harness/scaffold.mjs b/eval/harness/scaffold.mjs index e9dab01..c94dada 100644 --- a/eval/harness/scaffold.mjs +++ b/eval/harness/scaffold.mjs @@ -4,15 +4,24 @@ // /.prompt. // --base makes a change task (round 4): it copies an existing app's source files from into src/, and the // task's BASE.md (the brief that app was built to) next to BRIEF.md, and the prompt asks for a change, not a new app. -// Usage: node harness/scaffold.mjs [--reference] [--brief ] [--base ] -import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +// Round 7's chain steps (tasks/chain1..4, harness/make-chain.mjs) take the previous step's run's src/ as the base. +// --inline-docs (round 5) puts the .rx guide's two pages (guide/language.md, guide/async.md) at the end of the .rx +// prompt, so reading them costs no tool calls, and Solid's own agent primer (solid-js/skills/solid-2.0/SKILL.md) at +// the end of the Solid prompt; the React prompt is unchanged. +// For foldkit, the docs are Foldkit's own FOLDKIT.md (create-foldkit-app's template) at the end of the prompt, and the +// Foldkit repository at the installed release's tag readable in docs/foldkit, as Foldkit tells agents to vendor it +// (examples, skills, AGENTS.md, the framework source); FOLDKIT_REPO names the checkout (default ../../foldkit). +// Arms: rx, react, solid (Solid 2 TSX, round 5), foldkit (round 6). +// Usage: node harness/scaffold.mjs [--reference] [--brief ] [--base ] [--inline-docs] +import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, symlinkSync, writeFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; const here = path.dirname(fileURLToPath(import.meta.url)); const evalDir = path.resolve(here, ".."); +const foldkitRepo = process.env.FOLDKIT_REPO ?? path.resolve(evalDir, "../../foldkit"); -export function scaffold(task, arm, dir, { reference = false, brief = null, base = null } = {}) { +export function scaffold(task, arm, dir, { reference = false, brief = null, base = null, inline = false } = {}) { const t = path.join(evalDir, "tasks", task); dir = path.resolve(dir); mkdirSync(path.join(dir, "src"), { recursive: true }); mkdirSync(path.join(dir, "tests"), { recursive: true }); @@ -23,11 +32,16 @@ export function scaffold(task, arm, dir, { reference = false, brief = null, base const flights = readFileSync(path.join(here, "flights.js"), "utf8"); writeFileSync(path.join(dir, "src/flights.js"), brief === "plain" ? flights.replace(/^(\/\/.*\n)+\n/, "") : flights); cpSync(path.join(t, arm === "rx" ? "api.d.rx" : "api.d.ts"), path.join(dir, `src/${arm === "rx" ? "api.d.rx" : "api.d.ts"}`)); + if (arm === "foldkit") writeFileSync(path.join(dir, "tsconfig.json"), JSON.stringify({ + compilerOptions: { target: "ES2022", module: "ESNext", lib: ["ES2022", "DOM"], moduleResolution: "bundler", strict: true, noUncheckedIndexedAccess: true, skipLibCheck: true, esModuleInterop: true, exactOptionalPropertyTypes: true, isolatedModules: true, noEmit: true, allowJs: true, checkJs: false }, + include: ["src/**/*"], + }, null, 2) + "\n"); cpSync(path.join(t, "visible.mjs"), path.join(dir, "tests/visible.mjs")); cpSync(path.join(t, "fixtures.mjs"), path.join(dir, "tests/fixtures.mjs")); - if (arm === "react") { + if (arm === "react" || arm === "solid") { + const jsx = arm === "react" ? { jsx: "react-jsx" } : { jsx: "preserve", jsxImportSource: "@solidjs/web" }; writeFileSync(path.join(dir, "tsconfig.json"), JSON.stringify({ - compilerOptions: { target: "ES2022", module: "ESNext", moduleResolution: "bundler", jsx: "react-jsx", strict: true, noEmit: true, allowJs: true, checkJs: false, skipLibCheck: true, lib: ["ES2022", "DOM", "DOM.Iterable"] }, + compilerOptions: { target: "ES2022", module: "ESNext", moduleResolution: "bundler", ...jsx, strict: true, noEmit: true, allowJs: true, checkJs: false, skipLibCheck: true, lib: ["ES2022", "DOM", "DOM.Iterable"] }, include: ["src"], }, null, 2) + "\n"); } @@ -38,21 +52,35 @@ export function scaffold(task, arm, dir, { reference = false, brief = null, base cpSync(path.join(evalDir, "../spec"), path.join(dir, "docs/spec"), { recursive: true }); cpSync(path.join(evalDir, "../guide"), path.join(dir, "docs/guide"), { recursive: true }); } + if (arm === "foldkit") { + for (const p of ["README.md", "AGENTS.md", "examples", "skills", "packages/foldkit/src", "packages/create-foldkit-app/templates/base/FOLDKIT.md"]) + cpSync(path.join(foldkitRepo, p), path.join(dir, "docs/foldkit", p), { recursive: true }); + if (!existsSync(path.join(dir, "node_modules"))) symlinkSync(path.join(evalDir, "node_modules"), path.join(dir, "node_modules")); + } + // Solid's own docs ship in its package: the agent can read them there before its first build + if (arm === "solid" && !existsSync(path.join(dir, "node_modules"))) symlinkSync(path.join(evalDir, "node_modules"), path.join(dir, "node_modules")); if (reference) cpSync(path.join(evalDir, "reference", task, arm), path.join(dir, "src"), { recursive: true }); if (base) { cpSync(path.join(t, "BASE.md"), path.join(dir, "BASE.md")); - for (const f of readdirSync(base)) if (/\.(rx|tsx?)$/.test(f)) cpSync(path.join(base, f), path.join(dir, "src", f)); + // everything the earlier app had (a chain step's base is the previous agent's src/, which may hold more than + // .rx and .ts files), except the plumbing written above + const given = new Set(["api.js", "flights.js", "api.d.ts", "api.d.rx"]); + for (const f of readdirSync(base)) if (!given.has(f) && !f.startsWith(".")) cpSync(path.join(base, f), path.join(dir, "src", f), { recursive: true }); mkdirSync(path.join(dir, ".scratch")); } - writeFileSync(path.join(dir, ".prompt"), prompt(arm, dir, { plain: brief === "plain", change: !!base })); + writeFileSync(path.join(dir, ".prompt"), prompt(arm, dir, { plain: brief === "plain", change: !!base, inline })); return dir; } -export function prompt(arm, dir, { plain = false, change = false } = {}) { - const fill = { ...JSON.parse(readFileSync(path.join(here, "prompt-arms.json"), "utf8"))[arm] }; +export function prompt(arm, dir, { plain = false, change = false, inline = false } = {}) { + const docs = inline && arm !== "react"; + const fill = { ...JSON.parse(readFileSync(path.join(here, "prompt-arms.json"), "utf8"))[docs ? `${arm}-inline` : arm] }; fill.BACKEND_NOTE = plain ? "" : " whose answers the tests control"; let text = readFileSync(path.join(here, change ? "prompt-change.md" : "prompt.md"), "utf8"); for (let i = 0; i < 2; i++) for (const k of [...Object.keys(fill), "DIR"]) text = text.replaceAll(`{{${k}}}`, k === "DIR" ? dir : fill[k]); + if (docs && arm === "rx") for (const f of ["language.md", "async.md"]) text += `\n---\n\n${readFileSync(path.join(evalDir, "../guide", f), "utf8")}`; + if (docs && arm === "foldkit") text += `\n---\n\n${readFileSync(path.join(foldkitRepo, "packages/create-foldkit-app/templates/base/FOLDKIT.md"), "utf8")}`; + if (docs && arm === "solid") text += `\n---\n\n${readFileSync(path.join(evalDir, "node_modules/solid-js/skills/solid-2.0/SKILL.md"), "utf8").replace(/^---\n[\s\S]*?\n---\n+/, "")}`; return text; } @@ -60,5 +88,5 @@ if (import.meta.url === pathToFileURL(process.argv[1]).href) { const [task, arm, dir] = process.argv.slice(2); const b = process.argv.indexOf("--brief"); const base = process.argv.indexOf("--base"); - console.log(scaffold(task, arm, dir, { reference: process.argv.includes("--reference"), brief: b > 0 ? process.argv[b + 1] : null, base: base > 0 ? path.resolve(process.argv[base + 1]) : null })); + console.log(scaffold(task, arm, dir, { reference: process.argv.includes("--reference"), brief: b > 0 ? process.argv[b + 1] : null, base: base > 0 ? path.resolve(process.argv[base + 1]) : null, inline: process.argv.includes("--inline-docs") })); } diff --git a/eval/harness/score.mjs b/eval/harness/score.mjs index a7d840a..8d09a7b 100644 --- a/eval/harness/score.mjs +++ b/eval/harness/score.mjs @@ -15,14 +15,19 @@ const overlap = process.argv.includes("--overlap"); const [runsArg, outArg] = process.argv.slice(2).filter(a => a !== "--overlap"); const runs = path.resolve(runsArg); // solidjs/solid#4336: the .rx idiom for this case shows the bug in the runtime, not in the app's code. -const alongside = new Set(["a post on one issue does not show on another"]); +const round4Alongside = new Set(["a post on one issue does not show on another"]); +// solidjs/solid#4425 (round 5, rc.20): an Errored recovering after a retry waits for async under a nested Loading; +// it shows in the related task's .rx and Solid references alike. +const round5Alongside = new Set(["a post on one issue does not show on another", "base: retry after not found shows the issue"]); +const round5 = new Set(["desk", "labels", "related", "count", "chain1", "chain2", "chain3", "chain4"]); const rows = []; for (const name of readdirSync(runs).sort()) { const dir = path.join(runs, name); - const m = name.match(/^(\w+)-(rx|react)-(\d+)$/); + const m = name.match(/^(\w+)-(rx|react|solid|foldkit)-(\d+)$/); if (!m) continue; const [, task, arm, n] = m; + const alongside = round5.has(task) ? round5Alongside : round4Alongside; const log = existsSync(path.join(dir, ".runlog")) ? readFileSync(path.join(dir, ".runlog"), "utf8").trim().split("\n").filter(Boolean).map(l => JSON.parse(l)) : []; const firstGreen = log.findIndex(e => e.cmd === "test" && e.ok); const checked = check(dir); diff --git a/eval/package-lock.json b/eval/package-lock.json index 2df2468..60b1a7d 100644 --- a/eval/package-lock.json +++ b/eval/package-lock.json @@ -6,14 +6,19 @@ "": { "name": "rx-eval", "dependencies": { + "@effect/platform-browser": "^4.1.0", + "@solidjs/compiler": "file:../../solid-next/packages/compiler", "@solidjs/signals": "file:../../solid-next/packages/signals", "@solidjs/web": "file:../../solid-next/packages/web", "@tanstack/react-query": "5.90.2", "@types/react": "19.2.2", "@types/react-dom": "19.2.1", "@vitejs/plugin-react": "5.0.4", + "effect": "^4.1.0", "eslint": "9.37.0", "eslint-plugin-react-hooks": "7.0.0", + "eslint-plugin-solid": "^0.18.1", + "foldkit": "^0.170.0", "react": "19.2.0", "react-dom": "19.2.0", "solid-js": "file:../../solid-next/packages/solid", @@ -22,9 +27,29 @@ "vite": "7.1.9" } }, + "../../solid-next/packages/compiler": { + "name": "@solidjs/compiler", + "version": "2.0.0-rc.20", + "license": "MIT", + "devDependencies": { + "@emnapi/core": "^1.11.3", + "@emnapi/runtime": "^1.11.3", + "@napi-rs/cli": "^3.10.6", + "@napi-rs/wasm-runtime": "^1.1.6", + "emnapi": "^1.11.3" + }, + "optionalDependencies": { + "@solidjs/compiler-darwin-arm64": "2.0.0-rc.20", + "@solidjs/compiler-darwin-x64": "2.0.0-rc.20", + "@solidjs/compiler-linux-arm64-gnu": "2.0.0-rc.20", + "@solidjs/compiler-linux-x64-gnu": "2.0.0-rc.20", + "@solidjs/compiler-wasm32-wasi": "2.0.0-rc.20", + "@solidjs/compiler-win32-x64-msvc": "2.0.0-rc.20" + } + }, "../../solid-next/packages/signals": { "name": "@solidjs/signals", - "version": "2.0.0-rc.19", + "version": "2.0.0-rc.20", "license": "MIT", "devDependencies": { "@codspeed/vitest-plugin": "^5.4.0", @@ -47,10 +72,10 @@ }, "../../solid-next/packages/solid": { "name": "solid-js", - "version": "2.0.0-rc.19", + "version": "2.0.0-rc.20", "license": "MIT", "dependencies": { - "@solidjs/signals": "^2.0.0-rc.19", + "@solidjs/signals": "^2.0.0-rc.20", "csstype": "^3.1.0", "seroval": "1.7.0", "seroval-plugins": "1.7.0" @@ -61,7 +86,7 @@ }, "../../solid-next/packages/web": { "name": "@solidjs/web", - "version": "2.0.0-rc.19", + "version": "2.0.0-rc.20", "license": "MIT", "dependencies": { "seroval": "1.7.0", @@ -77,7 +102,7 @@ "node": ">=22.12.0" }, "peerDependencies": { - "solid-js": "^2.0.0-rc.19" + "solid-js": "^2.0.0-rc.20" } }, "node_modules/@babel/code-frame": { @@ -343,6 +368,15 @@ "node": ">=6.9.0" } }, + "node_modules/@effect/platform-browser": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@effect/platform-browser/-/platform-browser-4.1.0.tgz", + "integrity": "sha512-IEyaPzejKozHDupOUSjQIgQYGv2C0I828up8z7/nvQTHzmQHUS2/tGMEwXX/FwAvnrWZ0H9MhlhTl18ZgCHQiQ==", + "license": "MIT", + "peerDependencies": { + "effect": "^4.1.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -1405,6 +1439,10 @@ "win32" ] }, + "node_modules/@solidjs/compiler": { + "resolved": "../../solid-next/packages/compiler", + "link": true + }, "node_modules/@solidjs/signals": { "resolved": "../../solid-next/packages/signals", "link": true @@ -2036,12 +2074,30 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "license": "MIT" }, + "node_modules/effect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.1.0.tgz", + "integrity": "sha512-lGx2MJJyrXA8RZb2UpQ/0Nkb5Utaimby7oD92+18noBxLPE85IFOvptdzau8dvcxAOwfwHo9iqapbKeFzqQM/Q==", + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.465", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.465.tgz", "integrity": "sha512-zg4iUpbeGw7YNZsswc2xKr2uiBex2d8dELAXo92Icw3but1ULbrvK8USe6wVLaWjgJnKBARv0pchx9Owktx6wQ==", "license": "ISC" }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -2184,6 +2240,27 @@ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, + "node_modules/eslint-plugin-solid": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-solid/-/eslint-plugin-solid-0.18.1.tgz", + "integrity": "sha512-zxGSWGEfaVCujuOT358UVrBWuKEf91RP6bRbEIQaoX2d3VVP6R2+4NchdSajtpwNliw7eAJDjYBviuyPuSwmUQ==", + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.0.0", + "estraverse": "^5.3.0", + "is-html": "^2.0.0", + "kebab-case": "^1.0.2", + "known-css-properties": "^0.30.0", + "style-to-object": "^1.0.6" + }, + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "eslint": "^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4" + } + }, "node_modules/eslint-scope": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", @@ -2385,6 +2462,22 @@ "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", "license": "ISC" }, + "node_modules/foldkit": { + "version": "0.170.0", + "resolved": "https://registry.npmjs.org/foldkit/-/foldkit-0.170.0.tgz", + "integrity": "sha512-H/MRQe/IZ/LqpgPi+Zy1S1OXQ8o12e2S/6U+phQu3WmoHSm7Vase/dEnOvrq9YZfqxK44/oVq6wE9oqXzEIaig==", + "license": "MIT", + "dependencies": { + "parse5": "^8.0.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@effect/platform-browser": "~4.1.0", + "effect": "~4.1.0" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2462,6 +2555,18 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-tags": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz", + "integrity": "sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2496,6 +2601,12 @@ "node": ">=0.8.19" } }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2517,6 +2628,18 @@ "node": ">=0.10.0" } }, + "node_modules/is-html": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-html/-/is-html-2.0.0.tgz", + "integrity": "sha512-S+OpgB5i7wzIue/YSE5hg0e5ZYfG3hhpNh9KGl6ayJ38p7ED6wxQLd1TV91xHpcTvw90KMJ9EwN3F/iNflHBVg==", + "license": "MIT", + "dependencies": { + "html-tags": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -2602,6 +2725,12 @@ "node": ">=6" } }, + "node_modules/kebab-case": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/kebab-case/-/kebab-case-1.0.2.tgz", + "integrity": "sha512-7n6wXq4gNgBELfDCpzKc+mRrZFs7D+wgfF5WRFLNAr4DA/qtr9Js8uOAVAfHhuLMfAcQ0pRKqbpjx+TcJVdE1Q==", + "license": "MIT" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -2611,6 +2740,12 @@ "json-buffer": "3.0.1" } }, + "node_modules/known-css-properties": { + "version": "0.30.0", + "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.30.0.tgz", + "integrity": "sha512-VSWXYUnsPu9+WYKkfmJyLKtIvaRJi1kXUqVmBACORXZQxT5oZDsoZ2vQP+bQFDnWtpI/4eq3MLoRMjI2fnLzTQ==", + "license": "MIT" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -2786,6 +2921,18 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3066,6 +3213,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/eval/package.json b/eval/package.json index 2ca6344..505a86a 100644 --- a/eval/package.json +++ b/eval/package.json @@ -2,21 +2,26 @@ "name": "rx-eval", "private": true, "type": "module", - "description": "The .rx vs React benchmark harness (eval/README.md)", + "description": "The .rx vs React (and Solid 2 TSX) benchmark harness (eval/README.md)", "dependencies": { - "solid-js": "file:../../solid-next/packages/solid", - "@solidjs/web": "file:../../solid-next/packages/web", + "@effect/platform-browser": "^4.1.0", + "@solidjs/compiler": "file:../../solid-next/packages/compiler", "@solidjs/signals": "file:../../solid-next/packages/signals", - "react": "19.2.0", - "react-dom": "19.2.0", + "@solidjs/web": "file:../../solid-next/packages/web", "@tanstack/react-query": "5.90.2", "@types/react": "19.2.2", "@types/react-dom": "19.2.1", "@vitejs/plugin-react": "5.0.4", + "effect": "^4.1.0", "eslint": "9.37.0", "eslint-plugin-react-hooks": "7.0.0", - "typescript-eslint": "8.46.0", + "eslint-plugin-solid": "^0.18.1", + "foldkit": "^0.170.0", + "react": "19.2.0", + "react-dom": "19.2.0", + "solid-js": "file:../../solid-next/packages/solid", "typescript": "5.9.3", + "typescript-eslint": "8.46.0", "vite": "7.1.9" } } diff --git a/eval/reference/chain1/react/App.tsx b/eval/reference/chain1/react/App.tsx new file mode 100644 index 0000000..f36876b --- /dev/null +++ b/eval/reference/chain1/react/App.tsx @@ -0,0 +1,42 @@ +import { useState, type ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import IssuesPage from "./IssuesPage"; +import HelpPage from "./HelpPage"; +import SettingsPage from "./SettingsPage"; + +type Page = "issues" | "help" | "settings"; + +// Each page gets its own query cache, made when the page opens, so leaving a page discards what it loaded. +function PageClient({ children }: { children: ReactNode }) { + const [client] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, refetchOnWindowFocus: false, refetchOnReconnect: false }, + }, + }), + ); + return {children}; +} + +export default function App() { + const [page, setPage] = useState("issues"); + return ( +
    + + + {page === "issues" ? : page === "help" ? : } + +
    + ); +} diff --git a/eval/reference/chain1/react/CommentList.tsx b/eval/reference/chain1/react/CommentList.tsx new file mode 100644 index 0000000..3218198 --- /dev/null +++ b/eval/reference/chain1/react/CommentList.tsx @@ -0,0 +1,30 @@ +import type { Comment } from "./api"; +import { errorMessage } from "./errors"; + +export type Sending = { seq: number; issueId: number; author: string; text: string }; + +type Props = { + comments: Comment[] | undefined; + isPending: boolean; + error: unknown; + sending: Sending[]; +}; + +export default function CommentList({ comments, isPending, error, sending }: Props) { + if (isPending) return

    loading comments

    ; + if (error || !comments) return

    comments error: {errorMessage(error)}

    ; + return ( +
      + {comments.map((c, i) => ( +
    • + {c.author}: {c.text} +
    • + ))} + {sending.map(p => ( +
    • + {p.author}: {p.text} (sending…) +
    • + ))} +
    + ); +} diff --git a/eval/reference/chain1/react/HelpPage.tsx b/eval/reference/chain1/react/HelpPage.tsx new file mode 100644 index 0000000..020d0ed --- /dev/null +++ b/eval/reference/chain1/react/HelpPage.tsx @@ -0,0 +1,84 @@ +import { useState } from "react"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { fetchItem, search, type Hit } from "./api"; + +export default function HelpPage() { + const [query, setQuery] = useState(""); + const [selected, setSelected] = useState(null); + return ( +
    + setQuery(e.target.value)} />{" "} + {query !== "" && }{" "} + +
    + ); +} + +function Results({ query, selected, onSelect }: { query: string; selected: number | null; onSelect: (id: number) => void }) { + const tooShort = query.length < 2; + const { data, error, isFetching } = useQuery({ + queryKey: ["search", query], + queryFn: () => search(query), + enabled: !tooShort, + placeholderData: keepPreviousData, + }); + if (tooShort) + return ( +
    +

    too short: {query}

    +
    + ); + return ( +
    + {isFetching &&

    searching…

    } + {error && !isFetching ? ( +

    search failed: {error.message}

    + ) : ( + data && ( +
    +

    + {data.length} results for {query} +

    +
      + {data.map((hit: Hit) => ( +
    • onSelect(hit.id)}> + {hit.id === selected ? "> " : ""} + {hit.name} +
    • + ))} +
    +
    + ) + )} +
    + ); +} + +function HelpItem({ id }: { id: number | null }) { + const { data, error, isPending, refetch } = useQuery({ + queryKey: ["item", id], + queryFn: () => fetchItem(id!), + enabled: id !== null, + }); + if (id === null) return

    pick a result

    ; + return ( +
    + {isPending ? ( +

    loading…

    + ) : error ? ( + <> +

    failed: {error.message}

    {" "} + + + ) : data === null ? ( +

    item {id} not found

    + ) : ( +
    +

    {data.name}

    {data.blurb}

    +
    + )} +
    + ); +} diff --git a/eval/reference/chain1/react/IssueDetail.tsx b/eval/reference/chain1/react/IssueDetail.tsx new file mode 100644 index 0000000..8271510 --- /dev/null +++ b/eval/reference/chain1/react/IssueDetail.tsx @@ -0,0 +1,76 @@ +import type { Comment, IssueDetail as Detail } from "./api"; +import { errorMessage } from "./errors"; +import CommentList, { type Sending } from "./CommentList"; +import Labels from "./Labels"; + +type Props = { + id: number; + user: string; + detail: Detail | undefined; + loading: boolean; + error: unknown; + onRetry: () => void; + onAssign: () => void; + assignError: string | null; + titleInput: string; + onTitleInput: (title: string) => void; + onRename: () => void; + renameError: string | null; + onClose: () => void; + closeError: string | null; + comments: Comment[] | undefined; + commentsPending: boolean; + commentsError: unknown; + sending: Sending[]; + commentInput: string; + onCommentInput: (text: string) => void; + onPost: () => void; + postError: string | null; +}; + +export default function IssueDetail(p: Props) { + let body; + if (p.loading) body =

    loading #{p.id}

    ; + else if (p.error || !p.detail) body =

    detail error: {errorMessage(p.error)}

    ; + else + body = ( + <> +

    + #{p.detail.id} {p.detail.title} +

    +

    {p.detail.body}

    + + ); + return ( + <> + {body} + + + {p.assignError &&

    {p.assignError}

    } +
    + p.onTitleInput(e.target.value)} /> + + {p.renameError &&

    rename error: {p.renameError}

    } +
    + + {p.closeError &&

    close error: {p.closeError}

    } + +
    + +
    + p.onCommentInput(e.target.value)} /> + + {p.postError &&

    post error: {p.postError}

    } + + ); +} diff --git a/eval/reference/chain1/react/IssueList.tsx b/eval/reference/chain1/react/IssueList.tsx new file mode 100644 index 0000000..576ad8b --- /dev/null +++ b/eval/reference/chain1/react/IssueList.tsx @@ -0,0 +1,27 @@ +import type { Issue } from "./api"; +import { errorMessage } from "./errors"; + +type Props = { + filter: "open" | "closed"; + issues: Issue[] | undefined; + isPending: boolean; + error: unknown; + selected: number | null; + onSelect: (id: number) => void; +}; + +export default function IssueList({ filter, issues, isPending, error, selected, onSelect }: Props) { + if (isPending) return

    loading {filter} issues

    ; + if (error || !issues) return

    list error: {errorMessage(error)}

    ; + return ( +
      + {issues.map(issue => ( +
    • + +
    • + ))} +
    + ); +} diff --git a/eval/reference/chain1/react/IssuesPage.tsx b/eval/reference/chain1/react/IssuesPage.tsx new file mode 100644 index 0000000..9e16523 --- /dev/null +++ b/eval/reference/chain1/react/IssuesPage.tsx @@ -0,0 +1,233 @@ +import { useRef, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { addComment, assign, closeIssue, fetchComments, fetchIssue, fetchIssues, renameIssue } from "./api"; +import type { Issue, IssueDetail as Detail } from "./api"; +import { errorMessage, type IssueError } from "./errors"; +import IssueList from "./IssueList"; +import IssueDetail from "./IssueDetail"; +import type { Sending } from "./CommentList"; + +type Filter = "open" | "closed"; +// a write still in the air: `seq` orders writes to the same issue, the latest one is what the issue shows +type Guess = { seq: number; value: T }; +type Guesses = Record[]>; + +const latest = (guesses: Guesses, id: number): T | undefined => guesses[id]?.at(-1)?.value; +const addGuess = (prev: Guesses, id: number, g: Guess): Guesses => ({ ...prev, [id]: [...(prev[id] ?? []), g] }); +const dropGuess = (prev: Guesses, id: number, seq: number): Guesses => { + const rest = (prev[id] ?? []).filter(g => g.seq !== seq); + const next = { ...prev }; + if (rest.length) next[id] = rest; + else delete next[id]; + return next; +}; + +export default function IssuesPage() { + const queryClient = useQueryClient(); + const [user, setUser] = useState("guest"); + const [filter, setFilter] = useState("open"); + const [selected, setSelected] = useState(null); + const seqRef = useRef(0); + const nextSeq = () => ++seqRef.current; + + // assignments in the air, and the newest one confirmed per issue (an older answer must not overwrite it) + const [assigning, setAssigning] = useState>({}); + const [assignError, setAssignError] = useState(null); + const lastAssignRef = useRef(0); + const confirmedAssignRef = useRef>({}); + + const [renaming, setRenaming] = useState>({}); + const [renameError, setRenameError] = useState(null); + const [titleInput, setTitleInput] = useState(""); + const lastRenameRef = useRef(0); + const confirmedRenameRef = useRef>({}); + + // closes in the air, by issue id, with how many are out + const [closing, setClosing] = useState>({}); + const [closeError, setCloseError] = useState(null); + const lastCloseRef = useRef(0); + + const [sending, setSending] = useState([]); + const [postError, setPostError] = useState(null); + const [commentInput, setCommentInput] = useState(""); + const lastPostRef = useRef(0); + + const list = useQuery({ queryKey: ["issues", filter], queryFn: () => fetchIssues(filter) }); + + const detail = useQuery({ + queryKey: ["issue", selected], + queryFn: async () => { + const id = selected as number; + const d = await fetchIssue(id); + if (d === null) throw new Error(`issue #${id} not found`); + return d; + }, + enabled: selected !== null, + }); + + const comments = useQuery({ + queryKey: ["comments", selected], + queryFn: () => fetchComments(selected as number), + enabled: selected !== null, + }); + + const shownIssues = list.data + ?.filter(issue => !(filter === "open" && closing[issue.id])) + .map(issue => ({ + ...issue, + title: latest(renaming, issue.id) ?? issue.title, + assignee: latest(assigning, issue.id) ?? issue.assignee, + })); + const shownDetail = detail.data && { ...detail.data, title: latest(renaming, detail.data.id) ?? detail.data.title }; + + const select = (id: number) => { + setSelected(id); + setTitleInput(""); + setAssignError(null); + setRenameError(null); + setCloseError(null); + setPostError(null); + }; + + const doAssign = async (id: number, who: string) => { + const seq = nextSeq(); + lastAssignRef.current = seq; + setAssignError(null); + setAssigning(prev => addGuess(prev, id, { seq, value: who })); + try { + const res = await assign(id, who); + if ((confirmedAssignRef.current[id] ?? 0) < seq) { + confirmedAssignRef.current[id] = seq; + queryClient.setQueriesData({ queryKey: ["issues"] }, old => + old?.map(i => (i.id === id ? { ...i, assignee: res.assignee } : i)), + ); + } + } catch (e) { + // only report the failure if no later assignment has started since + if (lastAssignRef.current === seq) setAssignError({ issueId: id, message: errorMessage(e) }); + } finally { + setAssigning(prev => dropGuess(prev, id, seq)); + } + }; + + const doRename = async (id: number, title: string) => { + const seq = nextSeq(); + lastRenameRef.current = seq; + setRenameError(null); + setRenaming(prev => addGuess(prev, id, { seq, value: title })); + try { + const res = await renameIssue(id, title); + if ((confirmedRenameRef.current[id] ?? 0) < seq) { + confirmedRenameRef.current[id] = seq; + queryClient.setQueriesData({ queryKey: ["issues"] }, old => + old?.map(i => (i.id === id ? { ...i, title: res.title } : i)), + ); + queryClient.setQueryData(["issue", id], old => (old ? { ...old, title: res.title } : old)); + } + } catch (e) { + if (lastRenameRef.current === seq) setRenameError({ issueId: id, message: errorMessage(e) }); + } finally { + setRenaming(prev => dropGuess(prev, id, seq)); + } + }; + + const doClose = async (id: number) => { + const seq = nextSeq(); + lastCloseRef.current = seq; + setCloseError(null); + setClosing(prev => ({ ...prev, [id]: (prev[id] ?? 0) + 1 })); + try { + await closeIssue(id); + queryClient.setQueryData(["issues", "open"], old => old?.filter(i => i.id !== id)); + } catch (e) { + if (lastCloseRef.current === seq) setCloseError({ issueId: id, message: errorMessage(e) }); + } finally { + setClosing(prev => { + const next = { ...prev }; + if ((next[id] ?? 0) > 1) next[id] -= 1; + else delete next[id]; + return next; + }); + } + }; + + const doPost = async (id: number, author: string, text: string) => { + const seq = nextSeq(); + lastPostRef.current = seq; + setPostError(null); + setCommentInput(""); + setSending(prev => [...prev, { seq, issueId: id, author, text }]); + try { + const res = await addComment(id, author, text); + queryClient.setQueryData<{ author: string; text: string }[]>(["comments", id], old => (old ? [...old, res] : old)); + } catch (e) { + if (lastPostRef.current === seq) setPostError({ issueId: id, message: errorMessage(e) }); + } finally { + setSending(prev => prev.filter(p => p.seq !== seq)); + } + }; + + const errorFor = (e: IssueError | null) => (e && e.issueId === selected ? e.message : null); + + return ( +
    +
    + +
    + + +
    +
    + +
    +
    +
    + {selected === null ? ( +

    select an issue

    + ) : ( + void detail.refetch()} + onAssign={() => void doAssign(selected, user)} + assignError={errorFor(assignError)} + titleInput={titleInput} + onTitleInput={setTitleInput} + onRename={() => { + if (titleInput !== "") void doRename(selected, titleInput); + }} + renameError={errorFor(renameError)} + onClose={() => void doClose(selected)} + closeError={errorFor(closeError)} + comments={comments.data} + commentsPending={comments.isPending} + commentsError={comments.error} + sending={sending.filter(p => p.issueId === selected)} + commentInput={commentInput} + onCommentInput={setCommentInput} + onPost={() => { + if (commentInput !== "") void doPost(selected, user, commentInput); + }} + postError={errorFor(postError)} + /> + )} +
    +
    + ); +} diff --git a/eval/reference/chain1/react/Labels.tsx b/eval/reference/chain1/react/Labels.tsx new file mode 100644 index 0000000..2a922de --- /dev/null +++ b/eval/reference/chain1/react/Labels.tsx @@ -0,0 +1,77 @@ +import { useEffect, useRef, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { addLabel, fetchLabels, removeLabel } from "./api"; +import { errorMessage } from "./errors"; + +// a label change in the air, on one issue +type Change = { seq: number; issueId: number; label: string }; + +export default function Labels({ id }: { id: number }) { + const queryClient = useQueryClient(); + const labels = useQuery({ queryKey: ["labels", id], queryFn: () => fetchLabels(id) }); + const [adding, setAdding] = useState([]); + const [removing, setRemoving] = useState([]); + const [labelError, setLabelError] = useState(""); + const [labelInput, setLabelInput] = useState(""); + const seqRef = useRef(0); + + // a label error belongs to the issue it was made on: selecting another clears it + useEffect(() => setLabelError(""), [id]); + + const shown = [ + ...(labels.data ?? []).filter(l => !removing.some(r => r.issueId === id && r.label === l)), + ...adding.filter(a => a.issueId === id).map(a => a.label), + ]; + + const change = async (kind: "add" | "remove", issueId: number, label: string) => { + const seq = ++seqRef.current; + const set = kind === "add" ? setAdding : setRemoving; + setLabelError(""); + set(prev => [...prev, { seq, issueId, label }]); + try { + const after = await (kind === "add" ? addLabel(issueId, label) : removeLabel(issueId, label)); + queryClient.setQueryData(["labels", issueId], after); + } catch (e) { + if (seqRef.current === seq) setLabelError(errorMessage(e)); + } finally { + set(prev => prev.filter(c => c.seq !== seq)); + } + }; + + return ( +
    +
    + {labels.isPending ? ( +

    loading labels

    + ) : labels.isError ? ( +

    labels error: {errorMessage(labels.error)}

    + ) : ( +
      + {shown.map(l => ( +
    • + {l}{" "} + +
    • + ))} +
    + )} +
    + setLabelInput(e.target.value)} /> + + {labelError &&

    label error: {labelError}

    } +
    + ); +} diff --git a/eval/reference/chain1/react/SettingsPage.tsx b/eval/reference/chain1/react/SettingsPage.tsx new file mode 100644 index 0000000..d4ad74e --- /dev/null +++ b/eval/reference/chain1/react/SettingsPage.tsx @@ -0,0 +1,76 @@ +import { useRef, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { loadSettings, saveSettings, type Density, type Settings, type Theme } from "./api"; + +export default function SettingsPage() { + const queryClient = useQueryClient(); + const settings = useQuery({ queryKey: ["settings"], queryFn: loadSettings }); + const [saveError, setSaveError] = useState(""); + // saves still in the air, oldest first; the newest one shows until it answers or a newer one is confirmed + const [saving, setSaving] = useState<{ seq: number; value: Settings }[]>([]); + const seqRef = useRef(0); + const confirmedRef = useRef(0); + + const save = async (next: Settings) => { + const seq = ++seqRef.current; + setSaveError(""); + setSaving(prev => [...prev, { seq, value: next }]); + try { + const saved = await saveSettings(next); + if (seq > confirmedRef.current) { + confirmedRef.current = seq; + queryClient.setQueryData(["settings"], saved); + } + } catch (e) { + setSaveError(e instanceof Error ? e.message : String(e)); + } finally { + setSaving(prev => prev.filter(p => p.seq !== seq)); + } + }; + + if (settings.isPending) return

    Loading settings…

    ; + if (settings.error) return ( +
    +

    Could not load settings: {settings.error.message}

    {" "} + +
    + ); + const guess = saving.filter(p => p.seq > confirmedRef.current).at(-1); + const s = guess?.value ?? settings.data; + return ( +
    +

    Saved: {s.name} · {s.theme} · {s.density}{guess ? " (saving…)" : ""}

    {" "} + {saveError &&

    Save failed: {saveError}

    } + void save(draft)} /> +
    + ); +} + +function nameError(name: string) { + if (name.length === 0) return "Display name is required"; + if (name.length > 20) return `Display name must be at most 20 characters, not ${name.length}`; + return ""; +} + +// Seeded once from the loaded settings; after that the draft is the user's. +function SettingsForm({ initial, onSave }: { initial: Settings; onSave: (draft: Settings) => void }) { + const [name, setName] = useState(initial.name); + const [theme, setTheme] = useState(initial.theme); + const [density, setDensity] = useState(initial.density); + const error = nameError(name); + return ( +
    + {" "} + {name.length}/20{" "} + {error &&

    {error}

    } +
    + Theme: {theme}{" "} + {" "} + {" "} + {" "} +

    Preview: {theme} / {density}

    +
    + +
    + ); +} diff --git a/eval/reference/chain1/react/errors.ts b/eval/reference/chain1/react/errors.ts new file mode 100644 index 0000000..c380616 --- /dev/null +++ b/eval/reference/chain1/react/errors.ts @@ -0,0 +1,4 @@ +export const errorMessage = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +/** an error that belongs to one issue: shown only while that issue is selected */ +export type IssueError = { issueId: number; message: string }; diff --git a/eval/reference/chain1/rx/comments.rx b/eval/reference/chain1/rx/comments.rx new file mode 100644 index 0000000..3e9b7b2 --- /dev/null +++ b/eval/reference/chain1/rx/comments.rx @@ -0,0 +1,55 @@ +// The selected issue's comments, and posting one. Posts in flight live in their own source, so changing the +// selection never waits for them, and each shows only under its own issue. + +import pure { fetchComments } from "./api" +import { addComment } from "./api" + +export component Comments(props: { id: number; user: string }) { + source comments = await fetchComments(props.id) + source sending: { id: number; author: string; text: string }[] = [] + shownSending = sending.filter(s => s.id == props.id) + source postError: string | null = null + source commentInput: string := "" + + // a post error belongs to the issue it was made on: selecting another clears it + effect (props.id) { postError := null } + + action postComment(id: number, author: string, text: string) { + optimistic postError := null + try { + optimistic sending := [...sending, { id: id, author: author, text: text }] + await addComment(id, author, text) + refresh comments + } catch (e) { + postError := e.message + } + } + +
    +
    + {loading on(props.id) fallback {

    loading comments

    } { + {errored fallback(e) {

    comments error: {e.message}

    } { +
      + {for c in comments { +
    • {c.author}: {c.text}
    • + }} + {for s in shownSending { +
    • {s.author}: {s.text} (sending…)
    • + }} +
    + }} + }} +
    + { commentInput := e.target.value }} /> + + {if postError != null { +

    post error: {postError}

    + }} +
    +} diff --git a/eval/reference/chain1/rx/help.rx b/eval/reference/chain1/rx/help.rx new file mode 100644 index 0000000..7217eef --- /dev/null +++ b/eval/reference/chain1/rx/help.rx @@ -0,0 +1,52 @@ +// The help page: search the help catalogue and open one result. + +import pure { search, fetchItem } from "./api" + +export error TooShort(q: string) => `too short: ${q}` +export error ItemNotFound(id: number) => `item ${id} not found` + +fn foundItem(it, id: number) => it == null ? throw ItemNotFound(id) : it + +export component HelpPage() { + source query: string := "" + source selected: number | null := null + results = query == "" ? [] : query.length < 2 ? throw TooShort(query) : await search(query) + item = selected == null ? null : foundItem(await fetchItem(selected), selected) + +
    + { query := e.target.value }} /> + {if query != "" { +
    + {errored fallback(e) {

    search failed: {e.message}

    } { + errored fallback(e: TooShort) {

    {e.message}

    } { + loading fallback {

    searching…

    } { +

    {pending? results ? "searching…" : ""}

    +

    {results.length} results for {latest query}

    +
      + {for r in results { +
    • { selected := r.id }}>{r.id == selected ? "> " : ""}{r.name}
    • + }} +
    + } + } + }} +
    + }} + {if selected == null { +

    pick a result

    + } else { +
    + {loading on(selected) fallback {

    loading…

    } { + {for id in [selected] { + {errored fallback(e) {

    failed: {e.message}

    } { + {errored fallback(e: ItemNotFound) {

    {e.message}

    } { +

    {item.name}

    +

    {item.blurb}

    + }} + }} + }} + }} +
    + }} +
    +} diff --git a/eval/reference/chain1/rx/issues.rx b/eval/reference/chain1/rx/issues.rx new file mode 100644 index 0000000..73d5be5 --- /dev/null +++ b/eval/reference/chain1/rx/issues.rx @@ -0,0 +1,123 @@ +// The issues page: the filtered list, the selected issue's detail, and the writes on it (assign, rename, close). + +import pure { fetchIssues, fetchIssue } from "./api" +import { assign, renameIssue, closeIssue } from "./api" +import { Comments } from "./comments.rx" +import { Labels } from "./labels.rx" + +export error NotFound(id: number) => `issue #${id} not found` + +fn found(it, id: number) => it == null ? throw NotFound(id) : it + +export component IssuesPage() { + source user: string := "guest" + source filter: string := "open" + source selected: number | null := null + source issues key id = await fetchIssues(filter) + source detail = selected == null ? null : found(await fetchIssue(selected), selected) + source titleInput: string := "" + source assignError: string = "" + source renameError: string = "" + source closeError: string = "" + + action assignTo(id: number, who: string) { + optimistic assignError := "" + try { + let i = issues.findIndex(t => t.id == id) + optimistic issues[i].assignee := who + await assign(id, who) + refresh issues + } catch (e) { + assignError := e.message + } + } + + action renameTo(id: number, title: string) { + optimistic renameError := "" + try { + let i = issues.findIndex(t => t.id == id) + if (i >= 0) { + optimistic issues[i].title := title + } + if (detail != null && detail.id == id) { + optimistic detail.title := title + } + await renameIssue(id, title) + refresh issues + refresh detail + } catch (e) { + renameError := e.message + } + } + + action closeOne(id: number) { + optimistic closeError := "" + try { + optimistic issues := issues.filter(t => t.id != id) + await closeIssue(id) + refresh issues + } catch (e) { + closeError := e.message + } + } + +
    +
    + +
    +
    + + +
    +
    + {loading on(filter) fallback {

    loading {latest filter} issues

    } { + {errored fallback(e) {

    list error: {e.message}

    } { +
      + {for it in issues { +
    • { + selected := it.id + titleInput := "" + onFlush(() => { + assignError := "" + renameError := "" + closeError := "" + }) + }}>#{it.id} {it.title} ({it.assignee ?? "unassigned"})
    • + }} +
    + }} + }} +
    +
    + {if selected != null { + {loading on(selected) fallback {

    loading #{latest selected}

    } { + {errored fallback(e) {

    detail error: {e.message}

    } { +

    #{detail.id} {detail.title}

    +

    {detail.body}

    + }} + + }} + + {if assignError != "" { +

    {assignError}

    + }} + { titleInput := e.target.value }} /> + + {if renameError != "" { +

    rename error: {renameError}

    + }} + + {if closeError != "" { +

    close error: {closeError}

    + }} + + + } else { +

    select an issue

    + }} +
    +
    +} diff --git a/eval/reference/chain1/rx/labels.rx b/eval/reference/chain1/rx/labels.rx new file mode 100644 index 0000000..052b752 --- /dev/null +++ b/eval/reference/chain1/rx/labels.rx @@ -0,0 +1,67 @@ +// The selected issue's labels, adding and removing them. Changes in flight live in their own sources, so changing +// the selection never waits for them, and each shows only under its own issue. + +import pure { fetchLabels } from "./api" +import { addLabel, removeLabel } from "./api" + +export component Labels(props: { id: number }) { + source labels = await fetchLabels(props.id) + source adding: { id: number; label: string }[] = [] + source removing: { id: number; label: string }[] = [] + shown = [ + ...labels.filter(l => !removing.some(r => r.id == props.id && r.label == l)), + ...adding.filter(a => a.id == props.id).map(a => a.label), + ] + source labelError: string = "" + source labelInput: string := "" + + // a label error belongs to the issue it was made on: selecting another clears it + effect (props.id) { labelError := "" } + + action add(id: number, label: string) { + optimistic labelError := "" + try { + optimistic adding := [...adding, { id: id, label: label }] + await addLabel(id, label) + refresh labels + } catch (e) { + labelError := e.message + } + } + + action remove(id: number, label: string) { + optimistic labelError := "" + try { + optimistic removing := [...removing, { id: id, label: label }] + await removeLabel(id, label) + refresh labels + } catch (e) { + labelError := e.message + } + } + +
    +
    + {loading on(props.id) fallback {

    loading labels

    } { + {errored fallback(e) {

    labels error: {e.message}

    } { +
      + {for l in shown { +
    • {l}{" "}
    • + }} +
    + }} + }} +
    + { labelInput := e.target.value }} /> + + {if labelError != "" { +

    label error: {labelError}

    + }} +
    +} diff --git a/eval/reference/chain1/rx/main.rx b/eval/reference/chain1/rx/main.rx new file mode 100644 index 0000000..ffe006c --- /dev/null +++ b/eval/reference/chain1/rx/main.rx @@ -0,0 +1,26 @@ +// The desk: three pages behind tabs. Only the open page is mounted, so leaving one discards its state. + +import { IssuesPage } from "./issues.rx" +import { HelpPage } from "./help.rx" +import { SettingsPage } from "./settings.rx" + +export default component App() { + source page: string := "issues" + +
    + + {if page == "issues" { + + } else { + {if page == "help" { + + } else { + + }} + }} +
    +} diff --git a/eval/reference/chain1/rx/settings.rx b/eval/reference/chain1/rx/settings.rx new file mode 100644 index 0000000..0df8231 --- /dev/null +++ b/eval/reference/chain1/rx/settings.rx @@ -0,0 +1,61 @@ +// The settings page: the saved settings, a draft form seeded from them, and saving. + +import pure { loadSettings } from "./api" +import { saveSettings, Settings } from "./api" + +export error NameRequired() => "Display name is required" +export error NameTooLong(count: number) => `Display name must be at most 20 characters, not ${count}` + +fn validName(name: string): string => + name.length == 0 ? throw NameRequired() + : name.length > 20 ? throw NameTooLong(name.length) + : name + +export component SettingsPage() { + source settings: Settings = await loadSettings() + source saveError: string = "" + + action save(next: Settings) { + optimistic settings := next + optimistic saveError := "" + affects settings + try { + let saved = await saveSettings(next) + settings := saved + } catch (e) { + saveError := e.message + } + } + + errored fallback(e) { +

    Could not load settings: {e.message}

    + + } { + loading fallback {

    Loading settings…

    } { + source draftName: string := peek(settings.name) + source draftTheme: string := peek(settings.theme) + source draftDensity: string := peek(settings.density) + nameError: string = (validName(draftName) && "") catch (e: NameRequired | NameTooLong) => e.message +

    Saved: {settings.name} · {settings.theme} · {settings.density}{pending? settings ? " (saving…)" : ""}

    + {if saveError != "" { +

    Save failed: {saveError}

    + }} + {" "} + {draftName.length}/20 + {if nameError != "" { +

    {nameError}

    + }} +
    + {" "} + {" "} + +
    +

    Preview: {draftTheme} / {draftDensity}

    + + } + } +} diff --git a/eval/reference/chain2/react/App.tsx b/eval/reference/chain2/react/App.tsx new file mode 100644 index 0000000..563b80d --- /dev/null +++ b/eval/reference/chain2/react/App.tsx @@ -0,0 +1,43 @@ +import { useState, type ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import IssuesPage from "./IssuesPage"; +import HelpPage from "./HelpPage"; +import SettingsPage from "./SettingsPage"; + +type Page = "issues" | "help" | "settings"; + +// Each page gets its own query cache, made when the page opens, so leaving a page discards what it loaded. +function PageClient({ children }: { children: ReactNode }) { + const [client] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, refetchOnWindowFocus: false, refetchOnReconnect: false }, + }, + }), + ); + return {children}; +} + +export default function App() { + const [page, setPage] = useState("issues"); + const [openCount, setOpenCount] = useState(null); + return ( +
    + + + {page === "issues" ? : page === "help" ? : } + +
    + ); +} diff --git a/eval/reference/chain2/react/CommentList.tsx b/eval/reference/chain2/react/CommentList.tsx new file mode 100644 index 0000000..3218198 --- /dev/null +++ b/eval/reference/chain2/react/CommentList.tsx @@ -0,0 +1,30 @@ +import type { Comment } from "./api"; +import { errorMessage } from "./errors"; + +export type Sending = { seq: number; issueId: number; author: string; text: string }; + +type Props = { + comments: Comment[] | undefined; + isPending: boolean; + error: unknown; + sending: Sending[]; +}; + +export default function CommentList({ comments, isPending, error, sending }: Props) { + if (isPending) return

    loading comments

    ; + if (error || !comments) return

    comments error: {errorMessage(error)}

    ; + return ( +
      + {comments.map((c, i) => ( +
    • + {c.author}: {c.text} +
    • + ))} + {sending.map(p => ( +
    • + {p.author}: {p.text} (sending…) +
    • + ))} +
    + ); +} diff --git a/eval/reference/chain2/react/HelpPage.tsx b/eval/reference/chain2/react/HelpPage.tsx new file mode 100644 index 0000000..020d0ed --- /dev/null +++ b/eval/reference/chain2/react/HelpPage.tsx @@ -0,0 +1,84 @@ +import { useState } from "react"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { fetchItem, search, type Hit } from "./api"; + +export default function HelpPage() { + const [query, setQuery] = useState(""); + const [selected, setSelected] = useState(null); + return ( +
    + setQuery(e.target.value)} />{" "} + {query !== "" && }{" "} + +
    + ); +} + +function Results({ query, selected, onSelect }: { query: string; selected: number | null; onSelect: (id: number) => void }) { + const tooShort = query.length < 2; + const { data, error, isFetching } = useQuery({ + queryKey: ["search", query], + queryFn: () => search(query), + enabled: !tooShort, + placeholderData: keepPreviousData, + }); + if (tooShort) + return ( +
    +

    too short: {query}

    +
    + ); + return ( +
    + {isFetching &&

    searching…

    } + {error && !isFetching ? ( +

    search failed: {error.message}

    + ) : ( + data && ( +
    +

    + {data.length} results for {query} +

    +
      + {data.map((hit: Hit) => ( +
    • onSelect(hit.id)}> + {hit.id === selected ? "> " : ""} + {hit.name} +
    • + ))} +
    +
    + ) + )} +
    + ); +} + +function HelpItem({ id }: { id: number | null }) { + const { data, error, isPending, refetch } = useQuery({ + queryKey: ["item", id], + queryFn: () => fetchItem(id!), + enabled: id !== null, + }); + if (id === null) return

    pick a result

    ; + return ( +
    + {isPending ? ( +

    loading…

    + ) : error ? ( + <> +

    failed: {error.message}

    {" "} + + + ) : data === null ? ( +

    item {id} not found

    + ) : ( +
    +

    {data.name}

    {data.blurb}

    +
    + )} +
    + ); +} diff --git a/eval/reference/chain2/react/IssueDetail.tsx b/eval/reference/chain2/react/IssueDetail.tsx new file mode 100644 index 0000000..8271510 --- /dev/null +++ b/eval/reference/chain2/react/IssueDetail.tsx @@ -0,0 +1,76 @@ +import type { Comment, IssueDetail as Detail } from "./api"; +import { errorMessage } from "./errors"; +import CommentList, { type Sending } from "./CommentList"; +import Labels from "./Labels"; + +type Props = { + id: number; + user: string; + detail: Detail | undefined; + loading: boolean; + error: unknown; + onRetry: () => void; + onAssign: () => void; + assignError: string | null; + titleInput: string; + onTitleInput: (title: string) => void; + onRename: () => void; + renameError: string | null; + onClose: () => void; + closeError: string | null; + comments: Comment[] | undefined; + commentsPending: boolean; + commentsError: unknown; + sending: Sending[]; + commentInput: string; + onCommentInput: (text: string) => void; + onPost: () => void; + postError: string | null; +}; + +export default function IssueDetail(p: Props) { + let body; + if (p.loading) body =

    loading #{p.id}

    ; + else if (p.error || !p.detail) body =

    detail error: {errorMessage(p.error)}

    ; + else + body = ( + <> +

    + #{p.detail.id} {p.detail.title} +

    +

    {p.detail.body}

    + + ); + return ( + <> + {body} + + + {p.assignError &&

    {p.assignError}

    } +
    + p.onTitleInput(e.target.value)} /> + + {p.renameError &&

    rename error: {p.renameError}

    } +
    + + {p.closeError &&

    close error: {p.closeError}

    } + +
    + +
    + p.onCommentInput(e.target.value)} /> + + {p.postError &&

    post error: {p.postError}

    } + + ); +} diff --git a/eval/reference/chain2/react/IssueList.tsx b/eval/reference/chain2/react/IssueList.tsx new file mode 100644 index 0000000..576ad8b --- /dev/null +++ b/eval/reference/chain2/react/IssueList.tsx @@ -0,0 +1,27 @@ +import type { Issue } from "./api"; +import { errorMessage } from "./errors"; + +type Props = { + filter: "open" | "closed"; + issues: Issue[] | undefined; + isPending: boolean; + error: unknown; + selected: number | null; + onSelect: (id: number) => void; +}; + +export default function IssueList({ filter, issues, isPending, error, selected, onSelect }: Props) { + if (isPending) return

    loading {filter} issues

    ; + if (error || !issues) return

    list error: {errorMessage(error)}

    ; + return ( +
      + {issues.map(issue => ( +
    • + +
    • + ))} +
    + ); +} diff --git a/eval/reference/chain2/react/IssuesPage.tsx b/eval/reference/chain2/react/IssuesPage.tsx new file mode 100644 index 0000000..9648a83 --- /dev/null +++ b/eval/reference/chain2/react/IssuesPage.tsx @@ -0,0 +1,240 @@ +import { useEffect, useRef, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { addComment, assign, closeIssue, fetchComments, fetchIssue, fetchIssues, renameIssue } from "./api"; +import type { Issue, IssueDetail as Detail } from "./api"; +import { errorMessage, type IssueError } from "./errors"; +import IssueList from "./IssueList"; +import IssueDetail from "./IssueDetail"; +import type { Sending } from "./CommentList"; + +type Filter = "open" | "closed"; +// a write still in the air: `seq` orders writes to the same issue, the latest one is what the issue shows +type Guess = { seq: number; value: T }; +type Guesses = Record[]>; + +const latest = (guesses: Guesses, id: number): T | undefined => guesses[id]?.at(-1)?.value; +const addGuess = (prev: Guesses, id: number, g: Guess): Guesses => ({ ...prev, [id]: [...(prev[id] ?? []), g] }); +const dropGuess = (prev: Guesses, id: number, seq: number): Guesses => { + const rest = (prev[id] ?? []).filter(g => g.seq !== seq); + const next = { ...prev }; + if (rest.length) next[id] = rest; + else delete next[id]; + return next; +}; + +export default function IssuesPage({ onOpenCount }: { onOpenCount: (n: number) => void }) { + const queryClient = useQueryClient(); + const [user, setUser] = useState("guest"); + const [filter, setFilter] = useState("open"); + const [selected, setSelected] = useState(null); + const seqRef = useRef(0); + const nextSeq = () => ++seqRef.current; + + // assignments in the air, and the newest one confirmed per issue (an older answer must not overwrite it) + const [assigning, setAssigning] = useState>({}); + const [assignError, setAssignError] = useState(null); + const lastAssignRef = useRef(0); + const confirmedAssignRef = useRef>({}); + + const [renaming, setRenaming] = useState>({}); + const [renameError, setRenameError] = useState(null); + const [titleInput, setTitleInput] = useState(""); + const lastRenameRef = useRef(0); + const confirmedRenameRef = useRef>({}); + + // closes in the air, by issue id, with how many are out + const [closing, setClosing] = useState>({}); + const [closeError, setCloseError] = useState(null); + const lastCloseRef = useRef(0); + + const [sending, setSending] = useState([]); + const [postError, setPostError] = useState(null); + const [commentInput, setCommentInput] = useState(""); + const lastPostRef = useRef(0); + + const list = useQuery({ queryKey: ["issues", filter], queryFn: () => fetchIssues(filter) }); + + const detail = useQuery({ + queryKey: ["issue", selected], + queryFn: async () => { + const id = selected as number; + const d = await fetchIssue(id); + if (d === null) throw new Error(`issue #${id} not found`); + return d; + }, + enabled: selected !== null, + }); + + const comments = useQuery({ + queryKey: ["comments", selected], + queryFn: () => fetchComments(selected as number), + enabled: selected !== null, + }); + + const shownIssues = list.data + ?.filter(issue => !(filter === "open" && closing[issue.id])) + .map(issue => ({ + ...issue, + title: latest(renaming, issue.id) ?? issue.title, + assignee: latest(assigning, issue.id) ?? issue.assignee, + })); + // the open list as shown (closes in flight already out of it) is the tab's count; other lists, loads and failures + // leave the count as it was + const openCount = filter === "open" && !list.isFetching && shownIssues ? shownIssues.length : null; + useEffect(() => { + if (openCount !== null) onOpenCount(openCount); + }, [openCount, onOpenCount]); + + const shownDetail = detail.data && { ...detail.data, title: latest(renaming, detail.data.id) ?? detail.data.title }; + + const select = (id: number) => { + setSelected(id); + setTitleInput(""); + setAssignError(null); + setRenameError(null); + setCloseError(null); + setPostError(null); + }; + + const doAssign = async (id: number, who: string) => { + const seq = nextSeq(); + lastAssignRef.current = seq; + setAssignError(null); + setAssigning(prev => addGuess(prev, id, { seq, value: who })); + try { + const res = await assign(id, who); + if ((confirmedAssignRef.current[id] ?? 0) < seq) { + confirmedAssignRef.current[id] = seq; + queryClient.setQueriesData({ queryKey: ["issues"] }, old => + old?.map(i => (i.id === id ? { ...i, assignee: res.assignee } : i)), + ); + } + } catch (e) { + // only report the failure if no later assignment has started since + if (lastAssignRef.current === seq) setAssignError({ issueId: id, message: errorMessage(e) }); + } finally { + setAssigning(prev => dropGuess(prev, id, seq)); + } + }; + + const doRename = async (id: number, title: string) => { + const seq = nextSeq(); + lastRenameRef.current = seq; + setRenameError(null); + setRenaming(prev => addGuess(prev, id, { seq, value: title })); + try { + const res = await renameIssue(id, title); + if ((confirmedRenameRef.current[id] ?? 0) < seq) { + confirmedRenameRef.current[id] = seq; + queryClient.setQueriesData({ queryKey: ["issues"] }, old => + old?.map(i => (i.id === id ? { ...i, title: res.title } : i)), + ); + queryClient.setQueryData(["issue", id], old => (old ? { ...old, title: res.title } : old)); + } + } catch (e) { + if (lastRenameRef.current === seq) setRenameError({ issueId: id, message: errorMessage(e) }); + } finally { + setRenaming(prev => dropGuess(prev, id, seq)); + } + }; + + const doClose = async (id: number) => { + const seq = nextSeq(); + lastCloseRef.current = seq; + setCloseError(null); + setClosing(prev => ({ ...prev, [id]: (prev[id] ?? 0) + 1 })); + try { + await closeIssue(id); + queryClient.setQueryData(["issues", "open"], old => old?.filter(i => i.id !== id)); + } catch (e) { + if (lastCloseRef.current === seq) setCloseError({ issueId: id, message: errorMessage(e) }); + } finally { + setClosing(prev => { + const next = { ...prev }; + if ((next[id] ?? 0) > 1) next[id] -= 1; + else delete next[id]; + return next; + }); + } + }; + + const doPost = async (id: number, author: string, text: string) => { + const seq = nextSeq(); + lastPostRef.current = seq; + setPostError(null); + setCommentInput(""); + setSending(prev => [...prev, { seq, issueId: id, author, text }]); + try { + const res = await addComment(id, author, text); + queryClient.setQueryData<{ author: string; text: string }[]>(["comments", id], old => (old ? [...old, res] : old)); + } catch (e) { + if (lastPostRef.current === seq) setPostError({ issueId: id, message: errorMessage(e) }); + } finally { + setSending(prev => prev.filter(p => p.seq !== seq)); + } + }; + + const errorFor = (e: IssueError | null) => (e && e.issueId === selected ? e.message : null); + + return ( +
    +
    + +
    + + +
    +
    + +
    +
    +
    + {selected === null ? ( +

    select an issue

    + ) : ( + void detail.refetch()} + onAssign={() => void doAssign(selected, user)} + assignError={errorFor(assignError)} + titleInput={titleInput} + onTitleInput={setTitleInput} + onRename={() => { + if (titleInput !== "") void doRename(selected, titleInput); + }} + renameError={errorFor(renameError)} + onClose={() => void doClose(selected)} + closeError={errorFor(closeError)} + comments={comments.data} + commentsPending={comments.isPending} + commentsError={comments.error} + sending={sending.filter(p => p.issueId === selected)} + commentInput={commentInput} + onCommentInput={setCommentInput} + onPost={() => { + if (commentInput !== "") void doPost(selected, user, commentInput); + }} + postError={errorFor(postError)} + /> + )} +
    +
    + ); +} diff --git a/eval/reference/chain2/react/Labels.tsx b/eval/reference/chain2/react/Labels.tsx new file mode 100644 index 0000000..2a922de --- /dev/null +++ b/eval/reference/chain2/react/Labels.tsx @@ -0,0 +1,77 @@ +import { useEffect, useRef, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { addLabel, fetchLabels, removeLabel } from "./api"; +import { errorMessage } from "./errors"; + +// a label change in the air, on one issue +type Change = { seq: number; issueId: number; label: string }; + +export default function Labels({ id }: { id: number }) { + const queryClient = useQueryClient(); + const labels = useQuery({ queryKey: ["labels", id], queryFn: () => fetchLabels(id) }); + const [adding, setAdding] = useState([]); + const [removing, setRemoving] = useState([]); + const [labelError, setLabelError] = useState(""); + const [labelInput, setLabelInput] = useState(""); + const seqRef = useRef(0); + + // a label error belongs to the issue it was made on: selecting another clears it + useEffect(() => setLabelError(""), [id]); + + const shown = [ + ...(labels.data ?? []).filter(l => !removing.some(r => r.issueId === id && r.label === l)), + ...adding.filter(a => a.issueId === id).map(a => a.label), + ]; + + const change = async (kind: "add" | "remove", issueId: number, label: string) => { + const seq = ++seqRef.current; + const set = kind === "add" ? setAdding : setRemoving; + setLabelError(""); + set(prev => [...prev, { seq, issueId, label }]); + try { + const after = await (kind === "add" ? addLabel(issueId, label) : removeLabel(issueId, label)); + queryClient.setQueryData(["labels", issueId], after); + } catch (e) { + if (seqRef.current === seq) setLabelError(errorMessage(e)); + } finally { + set(prev => prev.filter(c => c.seq !== seq)); + } + }; + + return ( +
    +
    + {labels.isPending ? ( +

    loading labels

    + ) : labels.isError ? ( +

    labels error: {errorMessage(labels.error)}

    + ) : ( +
      + {shown.map(l => ( +
    • + {l}{" "} + +
    • + ))} +
    + )} +
    + setLabelInput(e.target.value)} /> + + {labelError &&

    label error: {labelError}

    } +
    + ); +} diff --git a/eval/reference/chain2/react/SettingsPage.tsx b/eval/reference/chain2/react/SettingsPage.tsx new file mode 100644 index 0000000..d4ad74e --- /dev/null +++ b/eval/reference/chain2/react/SettingsPage.tsx @@ -0,0 +1,76 @@ +import { useRef, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { loadSettings, saveSettings, type Density, type Settings, type Theme } from "./api"; + +export default function SettingsPage() { + const queryClient = useQueryClient(); + const settings = useQuery({ queryKey: ["settings"], queryFn: loadSettings }); + const [saveError, setSaveError] = useState(""); + // saves still in the air, oldest first; the newest one shows until it answers or a newer one is confirmed + const [saving, setSaving] = useState<{ seq: number; value: Settings }[]>([]); + const seqRef = useRef(0); + const confirmedRef = useRef(0); + + const save = async (next: Settings) => { + const seq = ++seqRef.current; + setSaveError(""); + setSaving(prev => [...prev, { seq, value: next }]); + try { + const saved = await saveSettings(next); + if (seq > confirmedRef.current) { + confirmedRef.current = seq; + queryClient.setQueryData(["settings"], saved); + } + } catch (e) { + setSaveError(e instanceof Error ? e.message : String(e)); + } finally { + setSaving(prev => prev.filter(p => p.seq !== seq)); + } + }; + + if (settings.isPending) return

    Loading settings…

    ; + if (settings.error) return ( +
    +

    Could not load settings: {settings.error.message}

    {" "} + +
    + ); + const guess = saving.filter(p => p.seq > confirmedRef.current).at(-1); + const s = guess?.value ?? settings.data; + return ( +
    +

    Saved: {s.name} · {s.theme} · {s.density}{guess ? " (saving…)" : ""}

    {" "} + {saveError &&

    Save failed: {saveError}

    } + void save(draft)} /> +
    + ); +} + +function nameError(name: string) { + if (name.length === 0) return "Display name is required"; + if (name.length > 20) return `Display name must be at most 20 characters, not ${name.length}`; + return ""; +} + +// Seeded once from the loaded settings; after that the draft is the user's. +function SettingsForm({ initial, onSave }: { initial: Settings; onSave: (draft: Settings) => void }) { + const [name, setName] = useState(initial.name); + const [theme, setTheme] = useState(initial.theme); + const [density, setDensity] = useState(initial.density); + const error = nameError(name); + return ( +
    + {" "} + {name.length}/20{" "} + {error &&

    {error}

    } +
    + Theme: {theme}{" "} + {" "} + {" "} + {" "} +

    Preview: {theme} / {density}

    +
    + +
    + ); +} diff --git a/eval/reference/chain2/react/errors.ts b/eval/reference/chain2/react/errors.ts new file mode 100644 index 0000000..c380616 --- /dev/null +++ b/eval/reference/chain2/react/errors.ts @@ -0,0 +1,4 @@ +export const errorMessage = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +/** an error that belongs to one issue: shown only while that issue is selected */ +export type IssueError = { issueId: number; message: string }; diff --git a/eval/reference/chain2/rx/comments.rx b/eval/reference/chain2/rx/comments.rx new file mode 100644 index 0000000..3e9b7b2 --- /dev/null +++ b/eval/reference/chain2/rx/comments.rx @@ -0,0 +1,55 @@ +// The selected issue's comments, and posting one. Posts in flight live in their own source, so changing the +// selection never waits for them, and each shows only under its own issue. + +import pure { fetchComments } from "./api" +import { addComment } from "./api" + +export component Comments(props: { id: number; user: string }) { + source comments = await fetchComments(props.id) + source sending: { id: number; author: string; text: string }[] = [] + shownSending = sending.filter(s => s.id == props.id) + source postError: string | null = null + source commentInput: string := "" + + // a post error belongs to the issue it was made on: selecting another clears it + effect (props.id) { postError := null } + + action postComment(id: number, author: string, text: string) { + optimistic postError := null + try { + optimistic sending := [...sending, { id: id, author: author, text: text }] + await addComment(id, author, text) + refresh comments + } catch (e) { + postError := e.message + } + } + +
    +
    + {loading on(props.id) fallback {

    loading comments

    } { + {errored fallback(e) {

    comments error: {e.message}

    } { +
      + {for c in comments { +
    • {c.author}: {c.text}
    • + }} + {for s in shownSending { +
    • {s.author}: {s.text} (sending…)
    • + }} +
    + }} + }} +
    + { commentInput := e.target.value }} /> + + {if postError != null { +

    post error: {postError}

    + }} +
    +} diff --git a/eval/reference/chain2/rx/help.rx b/eval/reference/chain2/rx/help.rx new file mode 100644 index 0000000..7217eef --- /dev/null +++ b/eval/reference/chain2/rx/help.rx @@ -0,0 +1,52 @@ +// The help page: search the help catalogue and open one result. + +import pure { search, fetchItem } from "./api" + +export error TooShort(q: string) => `too short: ${q}` +export error ItemNotFound(id: number) => `item ${id} not found` + +fn foundItem(it, id: number) => it == null ? throw ItemNotFound(id) : it + +export component HelpPage() { + source query: string := "" + source selected: number | null := null + results = query == "" ? [] : query.length < 2 ? throw TooShort(query) : await search(query) + item = selected == null ? null : foundItem(await fetchItem(selected), selected) + +
    + { query := e.target.value }} /> + {if query != "" { +
    + {errored fallback(e) {

    search failed: {e.message}

    } { + errored fallback(e: TooShort) {

    {e.message}

    } { + loading fallback {

    searching…

    } { +

    {pending? results ? "searching…" : ""}

    +

    {results.length} results for {latest query}

    +
      + {for r in results { +
    • { selected := r.id }}>{r.id == selected ? "> " : ""}{r.name}
    • + }} +
    + } + } + }} +
    + }} + {if selected == null { +

    pick a result

    + } else { +
    + {loading on(selected) fallback {

    loading…

    } { + {for id in [selected] { + {errored fallback(e) {

    failed: {e.message}

    } { + {errored fallback(e: ItemNotFound) {

    {e.message}

    } { +

    {item.name}

    +

    {item.blurb}

    + }} + }} + }} + }} +
    + }} +
    +} diff --git a/eval/reference/chain2/rx/issues.rx b/eval/reference/chain2/rx/issues.rx new file mode 100644 index 0000000..15ef341 --- /dev/null +++ b/eval/reference/chain2/rx/issues.rx @@ -0,0 +1,131 @@ +// The issues page: the filtered list, the selected issue's detail, and the writes on it (assign, rename, close). + +import pure { fetchIssues, fetchIssue } from "./api" +import { assign, renameIssue, closeIssue } from "./api" +import { Comments } from "./comments.rx" +import { Labels } from "./labels.rx" + +export error NotFound(id: number) => `issue #${id} not found` + +fn found(it, id: number) => it == null ? throw NotFound(id) : it + +export component IssuesPage(props: { onOpenCount: (n: number) => void }) { + source user: string := "guest" + source filter: string := "open" + source selected: number | null := null + source issues key id = await fetchIssues(filter) + source detail = selected == null ? null : found(await fetchIssue(selected), selected) + source titleInput: string := "" + source assignError: string = "" + source renameError: string = "" + source closeError: string = "" + + // the open list as shown (closes in flight already out of it) is the tab's count; other lists, loads and failures + // leave the count as it was + effect ((filter == "open" ? issues.length : -1) catch (e) => -1) { + if ($0 >= 0) { + props.onOpenCount($0) + } + } + + action assignTo(id: number, who: string) { + optimistic assignError := "" + try { + let i = issues.findIndex(t => t.id == id) + optimistic issues[i].assignee := who + await assign(id, who) + refresh issues + } catch (e) { + assignError := e.message + } + } + + action renameTo(id: number, title: string) { + optimistic renameError := "" + try { + let i = issues.findIndex(t => t.id == id) + if (i >= 0) { + optimistic issues[i].title := title + } + if (detail != null && detail.id == id) { + optimistic detail.title := title + } + await renameIssue(id, title) + refresh issues + refresh detail + } catch (e) { + renameError := e.message + } + } + + action closeOne(id: number) { + optimistic closeError := "" + try { + optimistic issues := issues.filter(t => t.id != id) + await closeIssue(id) + refresh issues + } catch (e) { + closeError := e.message + } + } + +
    +
    + +
    +
    + + +
    +
    + {loading on(filter) fallback {

    loading {latest filter} issues

    } { + {errored fallback(e) {

    list error: {e.message}

    } { +
      + {for it in issues { +
    • { + selected := it.id + titleInput := "" + onFlush(() => { + assignError := "" + renameError := "" + closeError := "" + }) + }}>#{it.id} {it.title} ({it.assignee ?? "unassigned"})
    • + }} +
    + }} + }} +
    +
    + {if selected != null { + {loading on(selected) fallback {

    loading #{latest selected}

    } { + {errored fallback(e) {

    detail error: {e.message}

    } { +

    #{detail.id} {detail.title}

    +

    {detail.body}

    + }} + + }} + + {if assignError != "" { +

    {assignError}

    + }} + { titleInput := e.target.value }} /> + + {if renameError != "" { +

    rename error: {renameError}

    + }} + + {if closeError != "" { +

    close error: {closeError}

    + }} + + + } else { +

    select an issue

    + }} +
    +
    +} diff --git a/eval/reference/chain2/rx/labels.rx b/eval/reference/chain2/rx/labels.rx new file mode 100644 index 0000000..052b752 --- /dev/null +++ b/eval/reference/chain2/rx/labels.rx @@ -0,0 +1,67 @@ +// The selected issue's labels, adding and removing them. Changes in flight live in their own sources, so changing +// the selection never waits for them, and each shows only under its own issue. + +import pure { fetchLabels } from "./api" +import { addLabel, removeLabel } from "./api" + +export component Labels(props: { id: number }) { + source labels = await fetchLabels(props.id) + source adding: { id: number; label: string }[] = [] + source removing: { id: number; label: string }[] = [] + shown = [ + ...labels.filter(l => !removing.some(r => r.id == props.id && r.label == l)), + ...adding.filter(a => a.id == props.id).map(a => a.label), + ] + source labelError: string = "" + source labelInput: string := "" + + // a label error belongs to the issue it was made on: selecting another clears it + effect (props.id) { labelError := "" } + + action add(id: number, label: string) { + optimistic labelError := "" + try { + optimistic adding := [...adding, { id: id, label: label }] + await addLabel(id, label) + refresh labels + } catch (e) { + labelError := e.message + } + } + + action remove(id: number, label: string) { + optimistic labelError := "" + try { + optimistic removing := [...removing, { id: id, label: label }] + await removeLabel(id, label) + refresh labels + } catch (e) { + labelError := e.message + } + } + +
    +
    + {loading on(props.id) fallback {

    loading labels

    } { + {errored fallback(e) {

    labels error: {e.message}

    } { +
      + {for l in shown { +
    • {l}{" "}
    • + }} +
    + }} + }} +
    + { labelInput := e.target.value }} /> + + {if labelError != "" { +

    label error: {labelError}

    + }} +
    +} diff --git a/eval/reference/chain2/rx/main.rx b/eval/reference/chain2/rx/main.rx new file mode 100644 index 0000000..c7404b0 --- /dev/null +++ b/eval/reference/chain2/rx/main.rx @@ -0,0 +1,27 @@ +// The desk: three pages behind tabs. Only the open page is mounted, so leaving one discards its state. + +import { IssuesPage } from "./issues.rx" +import { HelpPage } from "./help.rx" +import { SettingsPage } from "./settings.rx" + +export default component App() { + source page: string := "issues" + source openCount: number | null := null + +
    + + {if page == "issues" { + { openCount := n }} /> + } else { + {if page == "help" { + + } else { + + }} + }} +
    +} diff --git a/eval/reference/chain2/rx/settings.rx b/eval/reference/chain2/rx/settings.rx new file mode 100644 index 0000000..0df8231 --- /dev/null +++ b/eval/reference/chain2/rx/settings.rx @@ -0,0 +1,61 @@ +// The settings page: the saved settings, a draft form seeded from them, and saving. + +import pure { loadSettings } from "./api" +import { saveSettings, Settings } from "./api" + +export error NameRequired() => "Display name is required" +export error NameTooLong(count: number) => `Display name must be at most 20 characters, not ${count}` + +fn validName(name: string): string => + name.length == 0 ? throw NameRequired() + : name.length > 20 ? throw NameTooLong(name.length) + : name + +export component SettingsPage() { + source settings: Settings = await loadSettings() + source saveError: string = "" + + action save(next: Settings) { + optimistic settings := next + optimistic saveError := "" + affects settings + try { + let saved = await saveSettings(next) + settings := saved + } catch (e) { + saveError := e.message + } + } + + errored fallback(e) { +

    Could not load settings: {e.message}

    + + } { + loading fallback {

    Loading settings…

    } { + source draftName: string := peek(settings.name) + source draftTheme: string := peek(settings.theme) + source draftDensity: string := peek(settings.density) + nameError: string = (validName(draftName) && "") catch (e: NameRequired | NameTooLong) => e.message +

    Saved: {settings.name} · {settings.theme} · {settings.density}{pending? settings ? " (saving…)" : ""}

    + {if saveError != "" { +

    Save failed: {saveError}

    + }} + {" "} + {draftName.length}/20 + {if nameError != "" { +

    {nameError}

    + }} +
    + {" "} + {" "} + +
    +

    Preview: {draftTheme} / {draftDensity}

    + + } + } +} diff --git a/eval/reference/chain3/react/App.tsx b/eval/reference/chain3/react/App.tsx new file mode 100644 index 0000000..64d8651 --- /dev/null +++ b/eval/reference/chain3/react/App.tsx @@ -0,0 +1,55 @@ +import { useState, type ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import IssuesPage from "./IssuesPage"; +import HelpPage from "./HelpPage"; +import SettingsPage from "./SettingsPage"; + +type Page = "issues" | "help" | "settings"; + +// Each page gets its own query cache, made when the page opens, so leaving a page discards what it loaded. +function PageClient({ children }: { children: ReactNode }) { + const [client] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, refetchOnWindowFocus: false, refetchOnReconnect: false }, + }, + }), + ); + return {children}; +} + +export default function App() { + const [page, setPage] = useState("issues"); + const [openCount, setOpenCount] = useState(null); + // where the help page opens: empty from its tab, on an issue's title from a related hit + const [help, setHelp] = useState<{ query: string; selected: number | null; visit: number }>({ query: "", selected: null, visit: 0 }); + const openHelp = (query: string, selected: number | null) => { + setHelp(h => ({ query, selected, visit: h.visit + 1 })); + setPage("help"); + }; + return ( +
    + + + {page === "issues" ? ( + + ) : page === "help" ? ( + + ) : ( + + )} + +
    + ); +} diff --git a/eval/reference/chain3/react/CommentList.tsx b/eval/reference/chain3/react/CommentList.tsx new file mode 100644 index 0000000..3218198 --- /dev/null +++ b/eval/reference/chain3/react/CommentList.tsx @@ -0,0 +1,30 @@ +import type { Comment } from "./api"; +import { errorMessage } from "./errors"; + +export type Sending = { seq: number; issueId: number; author: string; text: string }; + +type Props = { + comments: Comment[] | undefined; + isPending: boolean; + error: unknown; + sending: Sending[]; +}; + +export default function CommentList({ comments, isPending, error, sending }: Props) { + if (isPending) return

    loading comments

    ; + if (error || !comments) return

    comments error: {errorMessage(error)}

    ; + return ( +
      + {comments.map((c, i) => ( +
    • + {c.author}: {c.text} +
    • + ))} + {sending.map(p => ( +
    • + {p.author}: {p.text} (sending…) +
    • + ))} +
    + ); +} diff --git a/eval/reference/chain3/react/HelpPage.tsx b/eval/reference/chain3/react/HelpPage.tsx new file mode 100644 index 0000000..beb7c91 --- /dev/null +++ b/eval/reference/chain3/react/HelpPage.tsx @@ -0,0 +1,86 @@ +import { useState } from "react"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { fetchItem, search, type Hit } from "./api"; + +// Opens on `initialQuery` with `initialSelected` chosen (both empty from the tab; a related hit opens it on its issue's +// title). +export default function HelpPage({ initialQuery = "", initialSelected = null }: { initialQuery?: string; initialSelected?: number | null }) { + const [query, setQuery] = useState(initialQuery); + const [selected, setSelected] = useState(initialSelected); + return ( +
    + setQuery(e.target.value)} />{" "} + {query !== "" && }{" "} + +
    + ); +} + +function Results({ query, selected, onSelect }: { query: string; selected: number | null; onSelect: (id: number) => void }) { + const tooShort = query.length < 2; + const { data, error, isFetching } = useQuery({ + queryKey: ["search", query], + queryFn: () => search(query), + enabled: !tooShort, + placeholderData: keepPreviousData, + }); + if (tooShort) + return ( +
    +

    too short: {query}

    +
    + ); + return ( +
    + {isFetching &&

    searching…

    } + {error && !isFetching ? ( +

    search failed: {error.message}

    + ) : ( + data && ( +
    +

    + {data.length} results for {query} +

    +
      + {data.map((hit: Hit) => ( +
    • onSelect(hit.id)}> + {hit.id === selected ? "> " : ""} + {hit.name} +
    • + ))} +
    +
    + ) + )} +
    + ); +} + +function HelpItem({ id }: { id: number | null }) { + const { data, error, isPending, refetch } = useQuery({ + queryKey: ["item", id], + queryFn: () => fetchItem(id!), + enabled: id !== null, + }); + if (id === null) return

    pick a result

    ; + return ( +
    + {isPending ? ( +

    loading…

    + ) : error ? ( + <> +

    failed: {error.message}

    {" "} + + + ) : data === null ? ( +

    item {id} not found

    + ) : ( +
    +

    {data.name}

    {data.blurb}

    +
    + )} +
    + ); +} diff --git a/eval/reference/chain3/react/IssueDetail.tsx b/eval/reference/chain3/react/IssueDetail.tsx new file mode 100644 index 0000000..6441749 --- /dev/null +++ b/eval/reference/chain3/react/IssueDetail.tsx @@ -0,0 +1,79 @@ +import type { Comment, IssueDetail as Detail } from "./api"; +import { errorMessage } from "./errors"; +import CommentList, { type Sending } from "./CommentList"; +import Labels from "./Labels"; +import Related from "./Related"; + +type Props = { + id: number; + user: string; + detail: Detail | undefined; + loading: boolean; + error: unknown; + onRetry: () => void; + onAssign: () => void; + assignError: string | null; + titleInput: string; + onTitleInput: (title: string) => void; + onRename: () => void; + renameError: string | null; + onClose: () => void; + closeError: string | null; + comments: Comment[] | undefined; + commentsPending: boolean; + commentsError: unknown; + sending: Sending[]; + commentInput: string; + onCommentInput: (text: string) => void; + onPost: () => void; + postError: string | null; + onOpenHelp: (query: string, id: number) => void; +}; + +export default function IssueDetail(p: Props) { + let body; + if (p.loading) body =

    loading #{p.id}

    ; + else if (p.error || !p.detail) body =

    detail error: {errorMessage(p.error)}

    ; + else + body = ( + <> +

    + #{p.detail.id} {p.detail.title} +

    +

    {p.detail.body}

    + + + ); + return ( + <> + {body} + + + {p.assignError &&

    {p.assignError}

    } +
    + p.onTitleInput(e.target.value)} /> + + {p.renameError &&

    rename error: {p.renameError}

    } +
    + + {p.closeError &&

    close error: {p.closeError}

    } + +
    + +
    + p.onCommentInput(e.target.value)} /> + + {p.postError &&

    post error: {p.postError}

    } + + ); +} diff --git a/eval/reference/chain3/react/IssueList.tsx b/eval/reference/chain3/react/IssueList.tsx new file mode 100644 index 0000000..576ad8b --- /dev/null +++ b/eval/reference/chain3/react/IssueList.tsx @@ -0,0 +1,27 @@ +import type { Issue } from "./api"; +import { errorMessage } from "./errors"; + +type Props = { + filter: "open" | "closed"; + issues: Issue[] | undefined; + isPending: boolean; + error: unknown; + selected: number | null; + onSelect: (id: number) => void; +}; + +export default function IssueList({ filter, issues, isPending, error, selected, onSelect }: Props) { + if (isPending) return

    loading {filter} issues

    ; + if (error || !issues) return

    list error: {errorMessage(error)}

    ; + return ( +
      + {issues.map(issue => ( +
    • + +
    • + ))} +
    + ); +} diff --git a/eval/reference/chain3/react/IssuesPage.tsx b/eval/reference/chain3/react/IssuesPage.tsx new file mode 100644 index 0000000..602a06b --- /dev/null +++ b/eval/reference/chain3/react/IssuesPage.tsx @@ -0,0 +1,241 @@ +import { useEffect, useRef, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { addComment, assign, closeIssue, fetchComments, fetchIssue, fetchIssues, renameIssue } from "./api"; +import type { Issue, IssueDetail as Detail } from "./api"; +import { errorMessage, type IssueError } from "./errors"; +import IssueList from "./IssueList"; +import IssueDetail from "./IssueDetail"; +import type { Sending } from "./CommentList"; + +type Filter = "open" | "closed"; +// a write still in the air: `seq` orders writes to the same issue, the latest one is what the issue shows +type Guess = { seq: number; value: T }; +type Guesses = Record[]>; + +const latest = (guesses: Guesses, id: number): T | undefined => guesses[id]?.at(-1)?.value; +const addGuess = (prev: Guesses, id: number, g: Guess): Guesses => ({ ...prev, [id]: [...(prev[id] ?? []), g] }); +const dropGuess = (prev: Guesses, id: number, seq: number): Guesses => { + const rest = (prev[id] ?? []).filter(g => g.seq !== seq); + const next = { ...prev }; + if (rest.length) next[id] = rest; + else delete next[id]; + return next; +}; + +export default function IssuesPage({ onOpenCount, onOpenHelp }: { onOpenCount: (n: number) => void; onOpenHelp: (query: string, id: number) => void }) { + const queryClient = useQueryClient(); + const [user, setUser] = useState("guest"); + const [filter, setFilter] = useState("open"); + const [selected, setSelected] = useState(null); + const seqRef = useRef(0); + const nextSeq = () => ++seqRef.current; + + // assignments in the air, and the newest one confirmed per issue (an older answer must not overwrite it) + const [assigning, setAssigning] = useState>({}); + const [assignError, setAssignError] = useState(null); + const lastAssignRef = useRef(0); + const confirmedAssignRef = useRef>({}); + + const [renaming, setRenaming] = useState>({}); + const [renameError, setRenameError] = useState(null); + const [titleInput, setTitleInput] = useState(""); + const lastRenameRef = useRef(0); + const confirmedRenameRef = useRef>({}); + + // closes in the air, by issue id, with how many are out + const [closing, setClosing] = useState>({}); + const [closeError, setCloseError] = useState(null); + const lastCloseRef = useRef(0); + + const [sending, setSending] = useState([]); + const [postError, setPostError] = useState(null); + const [commentInput, setCommentInput] = useState(""); + const lastPostRef = useRef(0); + + const list = useQuery({ queryKey: ["issues", filter], queryFn: () => fetchIssues(filter) }); + + const detail = useQuery({ + queryKey: ["issue", selected], + queryFn: async () => { + const id = selected as number; + const d = await fetchIssue(id); + if (d === null) throw new Error(`issue #${id} not found`); + return d; + }, + enabled: selected !== null, + }); + + const comments = useQuery({ + queryKey: ["comments", selected], + queryFn: () => fetchComments(selected as number), + enabled: selected !== null, + }); + + const shownIssues = list.data + ?.filter(issue => !(filter === "open" && closing[issue.id])) + .map(issue => ({ + ...issue, + title: latest(renaming, issue.id) ?? issue.title, + assignee: latest(assigning, issue.id) ?? issue.assignee, + })); + // the open list as shown (closes in flight already out of it) is the tab's count; other lists, loads and failures + // leave the count as it was + const openCount = filter === "open" && !list.isFetching && shownIssues ? shownIssues.length : null; + useEffect(() => { + if (openCount !== null) onOpenCount(openCount); + }, [openCount, onOpenCount]); + + const shownDetail = detail.data && { ...detail.data, title: latest(renaming, detail.data.id) ?? detail.data.title }; + + const select = (id: number) => { + setSelected(id); + setTitleInput(""); + setAssignError(null); + setRenameError(null); + setCloseError(null); + setPostError(null); + }; + + const doAssign = async (id: number, who: string) => { + const seq = nextSeq(); + lastAssignRef.current = seq; + setAssignError(null); + setAssigning(prev => addGuess(prev, id, { seq, value: who })); + try { + const res = await assign(id, who); + if ((confirmedAssignRef.current[id] ?? 0) < seq) { + confirmedAssignRef.current[id] = seq; + queryClient.setQueriesData({ queryKey: ["issues"] }, old => + old?.map(i => (i.id === id ? { ...i, assignee: res.assignee } : i)), + ); + } + } catch (e) { + // only report the failure if no later assignment has started since + if (lastAssignRef.current === seq) setAssignError({ issueId: id, message: errorMessage(e) }); + } finally { + setAssigning(prev => dropGuess(prev, id, seq)); + } + }; + + const doRename = async (id: number, title: string) => { + const seq = nextSeq(); + lastRenameRef.current = seq; + setRenameError(null); + setRenaming(prev => addGuess(prev, id, { seq, value: title })); + try { + const res = await renameIssue(id, title); + if ((confirmedRenameRef.current[id] ?? 0) < seq) { + confirmedRenameRef.current[id] = seq; + queryClient.setQueriesData({ queryKey: ["issues"] }, old => + old?.map(i => (i.id === id ? { ...i, title: res.title } : i)), + ); + queryClient.setQueryData(["issue", id], old => (old ? { ...old, title: res.title } : old)); + } + } catch (e) { + if (lastRenameRef.current === seq) setRenameError({ issueId: id, message: errorMessage(e) }); + } finally { + setRenaming(prev => dropGuess(prev, id, seq)); + } + }; + + const doClose = async (id: number) => { + const seq = nextSeq(); + lastCloseRef.current = seq; + setCloseError(null); + setClosing(prev => ({ ...prev, [id]: (prev[id] ?? 0) + 1 })); + try { + await closeIssue(id); + queryClient.setQueryData(["issues", "open"], old => old?.filter(i => i.id !== id)); + } catch (e) { + if (lastCloseRef.current === seq) setCloseError({ issueId: id, message: errorMessage(e) }); + } finally { + setClosing(prev => { + const next = { ...prev }; + if ((next[id] ?? 0) > 1) next[id] -= 1; + else delete next[id]; + return next; + }); + } + }; + + const doPost = async (id: number, author: string, text: string) => { + const seq = nextSeq(); + lastPostRef.current = seq; + setPostError(null); + setCommentInput(""); + setSending(prev => [...prev, { seq, issueId: id, author, text }]); + try { + const res = await addComment(id, author, text); + queryClient.setQueryData<{ author: string; text: string }[]>(["comments", id], old => (old ? [...old, res] : old)); + } catch (e) { + if (lastPostRef.current === seq) setPostError({ issueId: id, message: errorMessage(e) }); + } finally { + setSending(prev => prev.filter(p => p.seq !== seq)); + } + }; + + const errorFor = (e: IssueError | null) => (e && e.issueId === selected ? e.message : null); + + return ( +
    +
    + +
    + + +
    +
    + +
    +
    +
    + {selected === null ? ( +

    select an issue

    + ) : ( + void detail.refetch()} + onAssign={() => void doAssign(selected, user)} + assignError={errorFor(assignError)} + titleInput={titleInput} + onTitleInput={setTitleInput} + onRename={() => { + if (titleInput !== "") void doRename(selected, titleInput); + }} + renameError={errorFor(renameError)} + onClose={() => void doClose(selected)} + closeError={errorFor(closeError)} + comments={comments.data} + commentsPending={comments.isPending} + commentsError={comments.error} + sending={sending.filter(p => p.issueId === selected)} + commentInput={commentInput} + onCommentInput={setCommentInput} + onPost={() => { + if (commentInput !== "") void doPost(selected, user, commentInput); + }} + postError={errorFor(postError)} + onOpenHelp={onOpenHelp} + /> + )} +
    +
    + ); +} diff --git a/eval/reference/chain3/react/Labels.tsx b/eval/reference/chain3/react/Labels.tsx new file mode 100644 index 0000000..2a922de --- /dev/null +++ b/eval/reference/chain3/react/Labels.tsx @@ -0,0 +1,77 @@ +import { useEffect, useRef, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { addLabel, fetchLabels, removeLabel } from "./api"; +import { errorMessage } from "./errors"; + +// a label change in the air, on one issue +type Change = { seq: number; issueId: number; label: string }; + +export default function Labels({ id }: { id: number }) { + const queryClient = useQueryClient(); + const labels = useQuery({ queryKey: ["labels", id], queryFn: () => fetchLabels(id) }); + const [adding, setAdding] = useState([]); + const [removing, setRemoving] = useState([]); + const [labelError, setLabelError] = useState(""); + const [labelInput, setLabelInput] = useState(""); + const seqRef = useRef(0); + + // a label error belongs to the issue it was made on: selecting another clears it + useEffect(() => setLabelError(""), [id]); + + const shown = [ + ...(labels.data ?? []).filter(l => !removing.some(r => r.issueId === id && r.label === l)), + ...adding.filter(a => a.issueId === id).map(a => a.label), + ]; + + const change = async (kind: "add" | "remove", issueId: number, label: string) => { + const seq = ++seqRef.current; + const set = kind === "add" ? setAdding : setRemoving; + setLabelError(""); + set(prev => [...prev, { seq, issueId, label }]); + try { + const after = await (kind === "add" ? addLabel(issueId, label) : removeLabel(issueId, label)); + queryClient.setQueryData(["labels", issueId], after); + } catch (e) { + if (seqRef.current === seq) setLabelError(errorMessage(e)); + } finally { + set(prev => prev.filter(c => c.seq !== seq)); + } + }; + + return ( +
    +
    + {labels.isPending ? ( +

    loading labels

    + ) : labels.isError ? ( +

    labels error: {errorMessage(labels.error)}

    + ) : ( +
      + {shown.map(l => ( +
    • + {l}{" "} + +
    • + ))} +
    + )} +
    + setLabelInput(e.target.value)} /> + + {labelError &&

    label error: {labelError}

    } +
    + ); +} diff --git a/eval/reference/chain3/react/Related.tsx b/eval/reference/chain3/react/Related.tsx new file mode 100644 index 0000000..497375d --- /dev/null +++ b/eval/reference/chain3/react/Related.tsx @@ -0,0 +1,29 @@ +import { useQuery } from "@tanstack/react-query"; +import { search } from "./api"; +import { errorMessage } from "./errors"; + +/** help related to an issue: the first hits of a search for its title */ +export default function Related({ title, onOpen }: { title: string; onOpen: (query: string, id: number) => void }) { + const hits = useQuery({ queryKey: ["related", title], queryFn: () => search(title) }); + return ( +
    + {hits.isPending ? ( +

    finding related help

    + ) : hits.isError ? ( +

    related help failed: {errorMessage(hits.error)}

    + ) : hits.data.length === 0 ? ( +

    no related help

    + ) : ( +
      + {hits.data.slice(0, 3).map(h => ( +
    • + +
    • + ))} +
    + )} +
    + ); +} diff --git a/eval/reference/chain3/react/SettingsPage.tsx b/eval/reference/chain3/react/SettingsPage.tsx new file mode 100644 index 0000000..d4ad74e --- /dev/null +++ b/eval/reference/chain3/react/SettingsPage.tsx @@ -0,0 +1,76 @@ +import { useRef, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { loadSettings, saveSettings, type Density, type Settings, type Theme } from "./api"; + +export default function SettingsPage() { + const queryClient = useQueryClient(); + const settings = useQuery({ queryKey: ["settings"], queryFn: loadSettings }); + const [saveError, setSaveError] = useState(""); + // saves still in the air, oldest first; the newest one shows until it answers or a newer one is confirmed + const [saving, setSaving] = useState<{ seq: number; value: Settings }[]>([]); + const seqRef = useRef(0); + const confirmedRef = useRef(0); + + const save = async (next: Settings) => { + const seq = ++seqRef.current; + setSaveError(""); + setSaving(prev => [...prev, { seq, value: next }]); + try { + const saved = await saveSettings(next); + if (seq > confirmedRef.current) { + confirmedRef.current = seq; + queryClient.setQueryData(["settings"], saved); + } + } catch (e) { + setSaveError(e instanceof Error ? e.message : String(e)); + } finally { + setSaving(prev => prev.filter(p => p.seq !== seq)); + } + }; + + if (settings.isPending) return

    Loading settings…

    ; + if (settings.error) return ( +
    +

    Could not load settings: {settings.error.message}

    {" "} + +
    + ); + const guess = saving.filter(p => p.seq > confirmedRef.current).at(-1); + const s = guess?.value ?? settings.data; + return ( +
    +

    Saved: {s.name} · {s.theme} · {s.density}{guess ? " (saving…)" : ""}

    {" "} + {saveError &&

    Save failed: {saveError}

    } + void save(draft)} /> +
    + ); +} + +function nameError(name: string) { + if (name.length === 0) return "Display name is required"; + if (name.length > 20) return `Display name must be at most 20 characters, not ${name.length}`; + return ""; +} + +// Seeded once from the loaded settings; after that the draft is the user's. +function SettingsForm({ initial, onSave }: { initial: Settings; onSave: (draft: Settings) => void }) { + const [name, setName] = useState(initial.name); + const [theme, setTheme] = useState(initial.theme); + const [density, setDensity] = useState(initial.density); + const error = nameError(name); + return ( +
    + {" "} + {name.length}/20{" "} + {error &&

    {error}

    } +
    + Theme: {theme}{" "} + {" "} + {" "} + {" "} +

    Preview: {theme} / {density}

    +
    + +
    + ); +} diff --git a/eval/reference/chain3/react/errors.ts b/eval/reference/chain3/react/errors.ts new file mode 100644 index 0000000..c380616 --- /dev/null +++ b/eval/reference/chain3/react/errors.ts @@ -0,0 +1,4 @@ +export const errorMessage = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +/** an error that belongs to one issue: shown only while that issue is selected */ +export type IssueError = { issueId: number; message: string }; diff --git a/eval/reference/chain3/rx/comments.rx b/eval/reference/chain3/rx/comments.rx new file mode 100644 index 0000000..3e9b7b2 --- /dev/null +++ b/eval/reference/chain3/rx/comments.rx @@ -0,0 +1,55 @@ +// The selected issue's comments, and posting one. Posts in flight live in their own source, so changing the +// selection never waits for them, and each shows only under its own issue. + +import pure { fetchComments } from "./api" +import { addComment } from "./api" + +export component Comments(props: { id: number; user: string }) { + source comments = await fetchComments(props.id) + source sending: { id: number; author: string; text: string }[] = [] + shownSending = sending.filter(s => s.id == props.id) + source postError: string | null = null + source commentInput: string := "" + + // a post error belongs to the issue it was made on: selecting another clears it + effect (props.id) { postError := null } + + action postComment(id: number, author: string, text: string) { + optimistic postError := null + try { + optimistic sending := [...sending, { id: id, author: author, text: text }] + await addComment(id, author, text) + refresh comments + } catch (e) { + postError := e.message + } + } + +
    +
    + {loading on(props.id) fallback {

    loading comments

    } { + {errored fallback(e) {

    comments error: {e.message}

    } { +
      + {for c in comments { +
    • {c.author}: {c.text}
    • + }} + {for s in shownSending { +
    • {s.author}: {s.text} (sending…)
    • + }} +
    + }} + }} +
    + { commentInput := e.target.value }} /> + + {if postError != null { +

    post error: {postError}

    + }} +
    +} diff --git a/eval/reference/chain3/rx/help.rx b/eval/reference/chain3/rx/help.rx new file mode 100644 index 0000000..267e779 --- /dev/null +++ b/eval/reference/chain3/rx/help.rx @@ -0,0 +1,53 @@ +// The help page: search the help catalogue and open one result. + +import pure { search, fetchItem } from "./api" + +export error TooShort(q: string) => `too short: ${q}` +export error ItemNotFound(id: number) => `item ${id} not found` + +fn foundItem(it, id: number) => it == null ? throw ItemNotFound(id) : it + +// Opens on `query` with `selected` chosen (both empty from the tab; a related hit opens it on its issue's title). +export component HelpPage(props: { query: string; selected: number | null }) { + source query: string := peek(props.query) + source selected: number | null := peek(props.selected) + results = query == "" ? [] : query.length < 2 ? throw TooShort(query) : await search(query) + item = selected == null ? null : foundItem(await fetchItem(selected), selected) + +
    + { query := e.target.value }} /> + {if query != "" { +
    + {errored fallback(e) {

    search failed: {e.message}

    } { + errored fallback(e: TooShort) {

    {e.message}

    } { + loading fallback {

    searching…

    } { +

    {pending? results ? "searching…" : ""}

    +

    {results.length} results for {latest query}

    +
      + {for r in results { +
    • { selected := r.id }}>{r.id == selected ? "> " : ""}{r.name}
    • + }} +
    + } + } + }} +
    + }} + {if selected == null { +

    pick a result

    + } else { +
    + {loading on(selected) fallback {

    loading…

    } { + {for id in [selected] { + {errored fallback(e) {

    failed: {e.message}

    } { + {errored fallback(e: ItemNotFound) {

    {e.message}

    } { +

    {item.name}

    +

    {item.blurb}

    + }} + }} + }} + }} +
    + }} +
    +} diff --git a/eval/reference/chain3/rx/issues.rx b/eval/reference/chain3/rx/issues.rx new file mode 100644 index 0000000..356322b --- /dev/null +++ b/eval/reference/chain3/rx/issues.rx @@ -0,0 +1,133 @@ +// The issues page: the filtered list, the selected issue's detail, and the writes on it (assign, rename, close). + +import pure { fetchIssues, fetchIssue } from "./api" +import { assign, renameIssue, closeIssue } from "./api" +import { Comments } from "./comments.rx" +import { Labels } from "./labels.rx" +import { Related } from "./related.rx" + +export error NotFound(id: number) => `issue #${id} not found` + +fn found(it, id: number) => it == null ? throw NotFound(id) : it + +export component IssuesPage(props: { onOpenCount: (n: number) => void; onOpenHelp: (query: string, id: number) => void }) { + source user: string := "guest" + source filter: string := "open" + source selected: number | null := null + source issues key id = await fetchIssues(filter) + source detail = selected == null ? null : found(await fetchIssue(selected), selected) + source titleInput: string := "" + source assignError: string = "" + source renameError: string = "" + source closeError: string = "" + + // the open list as shown (closes in flight already out of it) is the tab's count; other lists, loads and failures + // leave the count as it was + effect ((filter == "open" ? issues.length : -1) catch (e) => -1) { + if ($0 >= 0) { + props.onOpenCount($0) + } + } + + action assignTo(id: number, who: string) { + optimistic assignError := "" + try { + let i = issues.findIndex(t => t.id == id) + optimistic issues[i].assignee := who + await assign(id, who) + refresh issues + } catch (e) { + assignError := e.message + } + } + + action renameTo(id: number, title: string) { + optimistic renameError := "" + try { + let i = issues.findIndex(t => t.id == id) + if (i >= 0) { + optimistic issues[i].title := title + } + if (detail != null && detail.id == id) { + optimistic detail.title := title + } + await renameIssue(id, title) + refresh issues + refresh detail + } catch (e) { + renameError := e.message + } + } + + action closeOne(id: number) { + optimistic closeError := "" + try { + optimistic issues := issues.filter(t => t.id != id) + await closeIssue(id) + refresh issues + } catch (e) { + closeError := e.message + } + } + +
    +
    + +
    +
    + + +
    +
    + {loading on(filter) fallback {

    loading {latest filter} issues

    } { + {errored fallback(e) {

    list error: {e.message}

    } { +
      + {for it in issues { +
    • { + selected := it.id + titleInput := "" + onFlush(() => { + assignError := "" + renameError := "" + closeError := "" + }) + }}>#{it.id} {it.title} ({it.assignee ?? "unassigned"})
    • + }} +
    + }} + }} +
    +
    + {if selected != null { + {loading on(selected) fallback {

    loading #{latest selected}

    } { + {errored fallback(e) {

    detail error: {e.message}

    } { +

    #{detail.id} {detail.title}

    +

    {detail.body}

    + { props.onOpenHelp(q, id) }} /> + }} + + }} + + {if assignError != "" { +

    {assignError}

    + }} + { titleInput := e.target.value }} /> + + {if renameError != "" { +

    rename error: {renameError}

    + }} + + {if closeError != "" { +

    close error: {closeError}

    + }} + + + } else { +

    select an issue

    + }} +
    +
    +} diff --git a/eval/reference/chain3/rx/labels.rx b/eval/reference/chain3/rx/labels.rx new file mode 100644 index 0000000..052b752 --- /dev/null +++ b/eval/reference/chain3/rx/labels.rx @@ -0,0 +1,67 @@ +// The selected issue's labels, adding and removing them. Changes in flight live in their own sources, so changing +// the selection never waits for them, and each shows only under its own issue. + +import pure { fetchLabels } from "./api" +import { addLabel, removeLabel } from "./api" + +export component Labels(props: { id: number }) { + source labels = await fetchLabels(props.id) + source adding: { id: number; label: string }[] = [] + source removing: { id: number; label: string }[] = [] + shown = [ + ...labels.filter(l => !removing.some(r => r.id == props.id && r.label == l)), + ...adding.filter(a => a.id == props.id).map(a => a.label), + ] + source labelError: string = "" + source labelInput: string := "" + + // a label error belongs to the issue it was made on: selecting another clears it + effect (props.id) { labelError := "" } + + action add(id: number, label: string) { + optimistic labelError := "" + try { + optimistic adding := [...adding, { id: id, label: label }] + await addLabel(id, label) + refresh labels + } catch (e) { + labelError := e.message + } + } + + action remove(id: number, label: string) { + optimistic labelError := "" + try { + optimistic removing := [...removing, { id: id, label: label }] + await removeLabel(id, label) + refresh labels + } catch (e) { + labelError := e.message + } + } + +
    +
    + {loading on(props.id) fallback {

    loading labels

    } { + {errored fallback(e) {

    labels error: {e.message}

    } { +
      + {for l in shown { +
    • {l}{" "}
    • + }} +
    + }} + }} +
    + { labelInput := e.target.value }} /> + + {if labelError != "" { +

    label error: {labelError}

    + }} +
    +} diff --git a/eval/reference/chain3/rx/main.rx b/eval/reference/chain3/rx/main.rx new file mode 100644 index 0000000..33b1881 --- /dev/null +++ b/eval/reference/chain3/rx/main.rx @@ -0,0 +1,37 @@ +// The desk: three pages behind tabs. Only the open page is mounted, so leaving one discards its state. + +import { IssuesPage } from "./issues.rx" +import { HelpPage } from "./help.rx" +import { SettingsPage } from "./settings.rx" + +export default component App() { + source page: string := "issues" + source openCount: number | null := null + source helpQuery: string := "" + source helpSelected: number | null := null + +
    + + {if page == "issues" { + { openCount := n }} onOpenHelp={(q, id) => { + helpQuery := q + helpSelected := id + page := "help" + }} /> + } else { + {if page == "help" { + + } else { + + }} + }} +
    +} diff --git a/eval/reference/chain3/rx/related.rx b/eval/reference/chain3/rx/related.rx new file mode 100644 index 0000000..aea6011 --- /dev/null +++ b/eval/reference/chain3/rx/related.rx @@ -0,0 +1,23 @@ +// Help related to an issue: the first hits of a search for its title. + +import pure { search } from "./api" + +export component Related(props: { title: string; onOpen: (query: string, id: number) => void }) { + hits = await search(props.title) + +
    + {loading on(props.title) fallback {

    finding related help

    } { + {errored fallback(e) {

    related help failed: {e.message}

    } { + {if hits.length == 0 { +

    no related help

    + } else { +
      + {for h in hits.slice(0, 3) { +
    • + }} +
    + }} + }} + }} +
    +} diff --git a/eval/reference/chain3/rx/settings.rx b/eval/reference/chain3/rx/settings.rx new file mode 100644 index 0000000..0df8231 --- /dev/null +++ b/eval/reference/chain3/rx/settings.rx @@ -0,0 +1,61 @@ +// The settings page: the saved settings, a draft form seeded from them, and saving. + +import pure { loadSettings } from "./api" +import { saveSettings, Settings } from "./api" + +export error NameRequired() => "Display name is required" +export error NameTooLong(count: number) => `Display name must be at most 20 characters, not ${count}` + +fn validName(name: string): string => + name.length == 0 ? throw NameRequired() + : name.length > 20 ? throw NameTooLong(name.length) + : name + +export component SettingsPage() { + source settings: Settings = await loadSettings() + source saveError: string = "" + + action save(next: Settings) { + optimistic settings := next + optimistic saveError := "" + affects settings + try { + let saved = await saveSettings(next) + settings := saved + } catch (e) { + saveError := e.message + } + } + + errored fallback(e) { +

    Could not load settings: {e.message}

    + + } { + loading fallback {

    Loading settings…

    } { + source draftName: string := peek(settings.name) + source draftTheme: string := peek(settings.theme) + source draftDensity: string := peek(settings.density) + nameError: string = (validName(draftName) && "") catch (e: NameRequired | NameTooLong) => e.message +

    Saved: {settings.name} · {settings.theme} · {settings.density}{pending? settings ? " (saving…)" : ""}

    + {if saveError != "" { +

    Save failed: {saveError}

    + }} + {" "} + {draftName.length}/20 + {if nameError != "" { +

    {nameError}

    + }} +
    + {" "} + {" "} + +
    +

    Preview: {draftTheme} / {draftDensity}

    + + } + } +} diff --git a/eval/reference/chain4/react/App.tsx b/eval/reference/chain4/react/App.tsx new file mode 100644 index 0000000..64d8651 --- /dev/null +++ b/eval/reference/chain4/react/App.tsx @@ -0,0 +1,55 @@ +import { useState, type ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import IssuesPage from "./IssuesPage"; +import HelpPage from "./HelpPage"; +import SettingsPage from "./SettingsPage"; + +type Page = "issues" | "help" | "settings"; + +// Each page gets its own query cache, made when the page opens, so leaving a page discards what it loaded. +function PageClient({ children }: { children: ReactNode }) { + const [client] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, refetchOnWindowFocus: false, refetchOnReconnect: false }, + }, + }), + ); + return {children}; +} + +export default function App() { + const [page, setPage] = useState("issues"); + const [openCount, setOpenCount] = useState(null); + // where the help page opens: empty from its tab, on an issue's title from a related hit + const [help, setHelp] = useState<{ query: string; selected: number | null; visit: number }>({ query: "", selected: null, visit: 0 }); + const openHelp = (query: string, selected: number | null) => { + setHelp(h => ({ query, selected, visit: h.visit + 1 })); + setPage("help"); + }; + return ( +
    + + + {page === "issues" ? ( + + ) : page === "help" ? ( + + ) : ( + + )} + +
    + ); +} diff --git a/eval/reference/chain4/react/CommentList.tsx b/eval/reference/chain4/react/CommentList.tsx new file mode 100644 index 0000000..3218198 --- /dev/null +++ b/eval/reference/chain4/react/CommentList.tsx @@ -0,0 +1,30 @@ +import type { Comment } from "./api"; +import { errorMessage } from "./errors"; + +export type Sending = { seq: number; issueId: number; author: string; text: string }; + +type Props = { + comments: Comment[] | undefined; + isPending: boolean; + error: unknown; + sending: Sending[]; +}; + +export default function CommentList({ comments, isPending, error, sending }: Props) { + if (isPending) return

    loading comments

    ; + if (error || !comments) return

    comments error: {errorMessage(error)}

    ; + return ( +
      + {comments.map((c, i) => ( +
    • + {c.author}: {c.text} +
    • + ))} + {sending.map(p => ( +
    • + {p.author}: {p.text} (sending…) +
    • + ))} +
    + ); +} diff --git a/eval/reference/chain4/react/HelpPage.tsx b/eval/reference/chain4/react/HelpPage.tsx new file mode 100644 index 0000000..beb7c91 --- /dev/null +++ b/eval/reference/chain4/react/HelpPage.tsx @@ -0,0 +1,86 @@ +import { useState } from "react"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { fetchItem, search, type Hit } from "./api"; + +// Opens on `initialQuery` with `initialSelected` chosen (both empty from the tab; a related hit opens it on its issue's +// title). +export default function HelpPage({ initialQuery = "", initialSelected = null }: { initialQuery?: string; initialSelected?: number | null }) { + const [query, setQuery] = useState(initialQuery); + const [selected, setSelected] = useState(initialSelected); + return ( +
    + setQuery(e.target.value)} />{" "} + {query !== "" && }{" "} + +
    + ); +} + +function Results({ query, selected, onSelect }: { query: string; selected: number | null; onSelect: (id: number) => void }) { + const tooShort = query.length < 2; + const { data, error, isFetching } = useQuery({ + queryKey: ["search", query], + queryFn: () => search(query), + enabled: !tooShort, + placeholderData: keepPreviousData, + }); + if (tooShort) + return ( +
    +

    too short: {query}

    +
    + ); + return ( +
    + {isFetching &&

    searching…

    } + {error && !isFetching ? ( +

    search failed: {error.message}

    + ) : ( + data && ( +
    +

    + {data.length} results for {query} +

    +
      + {data.map((hit: Hit) => ( +
    • onSelect(hit.id)}> + {hit.id === selected ? "> " : ""} + {hit.name} +
    • + ))} +
    +
    + ) + )} +
    + ); +} + +function HelpItem({ id }: { id: number | null }) { + const { data, error, isPending, refetch } = useQuery({ + queryKey: ["item", id], + queryFn: () => fetchItem(id!), + enabled: id !== null, + }); + if (id === null) return

    pick a result

    ; + return ( +
    + {isPending ? ( +

    loading…

    + ) : error ? ( + <> +

    failed: {error.message}

    {" "} + + + ) : data === null ? ( +

    item {id} not found

    + ) : ( +
    +

    {data.name}

    {data.blurb}

    +
    + )} +
    + ); +} diff --git a/eval/reference/chain4/react/IssueDetail.tsx b/eval/reference/chain4/react/IssueDetail.tsx new file mode 100644 index 0000000..6441749 --- /dev/null +++ b/eval/reference/chain4/react/IssueDetail.tsx @@ -0,0 +1,79 @@ +import type { Comment, IssueDetail as Detail } from "./api"; +import { errorMessage } from "./errors"; +import CommentList, { type Sending } from "./CommentList"; +import Labels from "./Labels"; +import Related from "./Related"; + +type Props = { + id: number; + user: string; + detail: Detail | undefined; + loading: boolean; + error: unknown; + onRetry: () => void; + onAssign: () => void; + assignError: string | null; + titleInput: string; + onTitleInput: (title: string) => void; + onRename: () => void; + renameError: string | null; + onClose: () => void; + closeError: string | null; + comments: Comment[] | undefined; + commentsPending: boolean; + commentsError: unknown; + sending: Sending[]; + commentInput: string; + onCommentInput: (text: string) => void; + onPost: () => void; + postError: string | null; + onOpenHelp: (query: string, id: number) => void; +}; + +export default function IssueDetail(p: Props) { + let body; + if (p.loading) body =

    loading #{p.id}

    ; + else if (p.error || !p.detail) body =

    detail error: {errorMessage(p.error)}

    ; + else + body = ( + <> +

    + #{p.detail.id} {p.detail.title} +

    +

    {p.detail.body}

    + + + ); + return ( + <> + {body} + + + {p.assignError &&

    {p.assignError}

    } +
    + p.onTitleInput(e.target.value)} /> + + {p.renameError &&

    rename error: {p.renameError}

    } +
    + + {p.closeError &&

    close error: {p.closeError}

    } + +
    + +
    + p.onCommentInput(e.target.value)} /> + + {p.postError &&

    post error: {p.postError}

    } + + ); +} diff --git a/eval/reference/chain4/react/IssueList.tsx b/eval/reference/chain4/react/IssueList.tsx new file mode 100644 index 0000000..f0fc75d --- /dev/null +++ b/eval/reference/chain4/react/IssueList.tsx @@ -0,0 +1,41 @@ +import type { Issue } from "./api"; +import { errorMessage } from "./errors"; + +type Props = { + filter: "open" | "closed"; + issues: Issue[] | undefined; + isPending: boolean; + error: unknown; + selected: number | null; + onSelect: (id: number) => void; + user: string; + onRowAssign: (id: number) => void; + onRowClose: (id: number) => void; +}; + +export default function IssueList({ filter, issues, isPending, error, selected, onSelect, user, onRowAssign, onRowClose }: Props) { + if (isPending) return

    loading {filter} issues

    ; + if (error || !issues) return

    list error: {errorMessage(error)}

    ; + return ( +
      + {issues.map(issue => ( +
    • + {" "} + + {filter === "open" && ( + <> + {" "} + + + )} +
    • + ))} +
    + ); +} diff --git a/eval/reference/chain4/react/IssuesPage.tsx b/eval/reference/chain4/react/IssuesPage.tsx new file mode 100644 index 0000000..7d8b2cf --- /dev/null +++ b/eval/reference/chain4/react/IssuesPage.tsx @@ -0,0 +1,266 @@ +import { useEffect, useRef, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { addComment, assign, closeIssue, fetchComments, fetchIssue, fetchIssues, renameIssue } from "./api"; +import type { Issue, IssueDetail as Detail } from "./api"; +import { errorMessage, type IssueError } from "./errors"; +import IssueList from "./IssueList"; +import IssueDetail from "./IssueDetail"; +import type { Sending } from "./CommentList"; + +type Filter = "open" | "closed"; +// a write still in the air: `seq` orders writes to the same issue, the latest one is what the issue shows +type Guess = { seq: number; value: T }; +type Guesses = Record[]>; + +const latest = (guesses: Guesses, id: number): T | undefined => guesses[id]?.at(-1)?.value; +const addGuess = (prev: Guesses, id: number, g: Guess): Guesses => ({ ...prev, [id]: [...(prev[id] ?? []), g] }); +const dropGuess = (prev: Guesses, id: number, seq: number): Guesses => { + const rest = (prev[id] ?? []).filter(g => g.seq !== seq); + const next = { ...prev }; + if (rest.length) next[id] = rest; + else delete next[id]; + return next; +}; + +export default function IssuesPage({ onOpenCount, onOpenHelp }: { onOpenCount: (n: number) => void; onOpenHelp: (query: string, id: number) => void }) { + const queryClient = useQueryClient(); + const [user, setUser] = useState("guest"); + const [filter, setFilter] = useState("open"); + const [selected, setSelected] = useState(null); + const seqRef = useRef(0); + const nextSeq = () => ++seqRef.current; + + // assignments in the air, and the newest one confirmed per issue (an older answer must not overwrite it) + const [assigning, setAssigning] = useState>({}); + const [assignError, setAssignError] = useState(null); + const lastAssignRef = useRef(0); + const confirmedAssignRef = useRef>({}); + + const [renaming, setRenaming] = useState>({}); + const [renameError, setRenameError] = useState(null); + const [titleInput, setTitleInput] = useState(""); + const lastRenameRef = useRef(0); + const confirmedRenameRef = useRef>({}); + + // closes in the air, by issue id, with how many are out + const [closing, setClosing] = useState>({}); + const [closeError, setCloseError] = useState(null); + const lastCloseRef = useRef(0); + + // the newest row action's failure; row actions and the detail's leave each other's errors alone + const [rowError, setRowError] = useState(null); + const lastRowRef = useRef(0); + + const [sending, setSending] = useState([]); + const [postError, setPostError] = useState(null); + const [commentInput, setCommentInput] = useState(""); + const lastPostRef = useRef(0); + + const list = useQuery({ queryKey: ["issues", filter], queryFn: () => fetchIssues(filter) }); + + const detail = useQuery({ + queryKey: ["issue", selected], + queryFn: async () => { + const id = selected as number; + const d = await fetchIssue(id); + if (d === null) throw new Error(`issue #${id} not found`); + return d; + }, + enabled: selected !== null, + }); + + const comments = useQuery({ + queryKey: ["comments", selected], + queryFn: () => fetchComments(selected as number), + enabled: selected !== null, + }); + + const shownIssues = list.data + ?.filter(issue => !(filter === "open" && closing[issue.id])) + .map(issue => ({ + ...issue, + title: latest(renaming, issue.id) ?? issue.title, + assignee: latest(assigning, issue.id) ?? issue.assignee, + })); + // the open list as shown (closes in flight already out of it) is the tab's count; other lists, loads and failures + // leave the count as it was + const openCount = filter === "open" && !list.isFetching && shownIssues ? shownIssues.length : null; + useEffect(() => { + if (openCount !== null) onOpenCount(openCount); + }, [openCount, onOpenCount]); + + const shownDetail = detail.data && { ...detail.data, title: latest(renaming, detail.data.id) ?? detail.data.title }; + + const select = (id: number) => { + setSelected(id); + setTitleInput(""); + setAssignError(null); + setRenameError(null); + setCloseError(null); + setPostError(null); + }; + + // a row action reports to the row error, the detail's to its own + const startRow = (seq: number) => { + lastRowRef.current = seq; + setRowError(null); + }; + const failRow = (seq: number, e: unknown) => { + if (lastRowRef.current === seq) setRowError(errorMessage(e)); + }; + + const doAssign = async (id: number, who: string, fromRow = false) => { + const seq = nextSeq(); + if (fromRow) startRow(seq); + else { + lastAssignRef.current = seq; + setAssignError(null); + } + setAssigning(prev => addGuess(prev, id, { seq, value: who })); + try { + const res = await assign(id, who); + if ((confirmedAssignRef.current[id] ?? 0) < seq) { + confirmedAssignRef.current[id] = seq; + queryClient.setQueriesData({ queryKey: ["issues"] }, old => + old?.map(i => (i.id === id ? { ...i, assignee: res.assignee } : i)), + ); + } + } catch (e) { + // only report the failure if no later assignment has started since + if (fromRow) failRow(seq, e); + else if (lastAssignRef.current === seq) setAssignError({ issueId: id, message: errorMessage(e) }); + } finally { + setAssigning(prev => dropGuess(prev, id, seq)); + } + }; + + const doRename = async (id: number, title: string) => { + const seq = nextSeq(); + lastRenameRef.current = seq; + setRenameError(null); + setRenaming(prev => addGuess(prev, id, { seq, value: title })); + try { + const res = await renameIssue(id, title); + if ((confirmedRenameRef.current[id] ?? 0) < seq) { + confirmedRenameRef.current[id] = seq; + queryClient.setQueriesData({ queryKey: ["issues"] }, old => + old?.map(i => (i.id === id ? { ...i, title: res.title } : i)), + ); + queryClient.setQueryData(["issue", id], old => (old ? { ...old, title: res.title } : old)); + } + } catch (e) { + if (lastRenameRef.current === seq) setRenameError({ issueId: id, message: errorMessage(e) }); + } finally { + setRenaming(prev => dropGuess(prev, id, seq)); + } + }; + + const doClose = async (id: number, fromRow = false) => { + const seq = nextSeq(); + if (fromRow) startRow(seq); + else { + lastCloseRef.current = seq; + setCloseError(null); + } + setClosing(prev => ({ ...prev, [id]: (prev[id] ?? 0) + 1 })); + try { + await closeIssue(id); + queryClient.setQueryData(["issues", "open"], old => old?.filter(i => i.id !== id)); + } catch (e) { + if (fromRow) failRow(seq, e); + else if (lastCloseRef.current === seq) setCloseError({ issueId: id, message: errorMessage(e) }); + } finally { + setClosing(prev => { + const next = { ...prev }; + if ((next[id] ?? 0) > 1) next[id] -= 1; + else delete next[id]; + return next; + }); + } + }; + + const doPost = async (id: number, author: string, text: string) => { + const seq = nextSeq(); + lastPostRef.current = seq; + setPostError(null); + setCommentInput(""); + setSending(prev => [...prev, { seq, issueId: id, author, text }]); + try { + const res = await addComment(id, author, text); + queryClient.setQueryData<{ author: string; text: string }[]>(["comments", id], old => (old ? [...old, res] : old)); + } catch (e) { + if (lastPostRef.current === seq) setPostError({ issueId: id, message: errorMessage(e) }); + } finally { + setSending(prev => prev.filter(p => p.seq !== seq)); + } + }; + + const errorFor = (e: IssueError | null) => (e && e.issueId === selected ? e.message : null); + + return ( +
    +
    + +
    + + +
    + {rowError !== null &&

    row error: {rowError}

    } +
    + void doAssign(id, user, true)} + onRowClose={id => void doClose(id, true)} + /> +
    +
    +
    + {selected === null ? ( +

    select an issue

    + ) : ( + void detail.refetch()} + onAssign={() => void doAssign(selected, user)} + assignError={errorFor(assignError)} + titleInput={titleInput} + onTitleInput={setTitleInput} + onRename={() => { + if (titleInput !== "") void doRename(selected, titleInput); + }} + renameError={errorFor(renameError)} + onClose={() => void doClose(selected)} + closeError={errorFor(closeError)} + comments={comments.data} + commentsPending={comments.isPending} + commentsError={comments.error} + sending={sending.filter(p => p.issueId === selected)} + commentInput={commentInput} + onCommentInput={setCommentInput} + onPost={() => { + if (commentInput !== "") void doPost(selected, user, commentInput); + }} + postError={errorFor(postError)} + onOpenHelp={onOpenHelp} + /> + )} +
    +
    + ); +} diff --git a/eval/reference/chain4/react/Labels.tsx b/eval/reference/chain4/react/Labels.tsx new file mode 100644 index 0000000..2a922de --- /dev/null +++ b/eval/reference/chain4/react/Labels.tsx @@ -0,0 +1,77 @@ +import { useEffect, useRef, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { addLabel, fetchLabels, removeLabel } from "./api"; +import { errorMessage } from "./errors"; + +// a label change in the air, on one issue +type Change = { seq: number; issueId: number; label: string }; + +export default function Labels({ id }: { id: number }) { + const queryClient = useQueryClient(); + const labels = useQuery({ queryKey: ["labels", id], queryFn: () => fetchLabels(id) }); + const [adding, setAdding] = useState([]); + const [removing, setRemoving] = useState([]); + const [labelError, setLabelError] = useState(""); + const [labelInput, setLabelInput] = useState(""); + const seqRef = useRef(0); + + // a label error belongs to the issue it was made on: selecting another clears it + useEffect(() => setLabelError(""), [id]); + + const shown = [ + ...(labels.data ?? []).filter(l => !removing.some(r => r.issueId === id && r.label === l)), + ...adding.filter(a => a.issueId === id).map(a => a.label), + ]; + + const change = async (kind: "add" | "remove", issueId: number, label: string) => { + const seq = ++seqRef.current; + const set = kind === "add" ? setAdding : setRemoving; + setLabelError(""); + set(prev => [...prev, { seq, issueId, label }]); + try { + const after = await (kind === "add" ? addLabel(issueId, label) : removeLabel(issueId, label)); + queryClient.setQueryData(["labels", issueId], after); + } catch (e) { + if (seqRef.current === seq) setLabelError(errorMessage(e)); + } finally { + set(prev => prev.filter(c => c.seq !== seq)); + } + }; + + return ( +
    +
    + {labels.isPending ? ( +

    loading labels

    + ) : labels.isError ? ( +

    labels error: {errorMessage(labels.error)}

    + ) : ( +
      + {shown.map(l => ( +
    • + {l}{" "} + +
    • + ))} +
    + )} +
    + setLabelInput(e.target.value)} /> + + {labelError &&

    label error: {labelError}

    } +
    + ); +} diff --git a/eval/reference/chain4/react/Related.tsx b/eval/reference/chain4/react/Related.tsx new file mode 100644 index 0000000..497375d --- /dev/null +++ b/eval/reference/chain4/react/Related.tsx @@ -0,0 +1,29 @@ +import { useQuery } from "@tanstack/react-query"; +import { search } from "./api"; +import { errorMessage } from "./errors"; + +/** help related to an issue: the first hits of a search for its title */ +export default function Related({ title, onOpen }: { title: string; onOpen: (query: string, id: number) => void }) { + const hits = useQuery({ queryKey: ["related", title], queryFn: () => search(title) }); + return ( +
    + {hits.isPending ? ( +

    finding related help

    + ) : hits.isError ? ( +

    related help failed: {errorMessage(hits.error)}

    + ) : hits.data.length === 0 ? ( +

    no related help

    + ) : ( +
      + {hits.data.slice(0, 3).map(h => ( +
    • + +
    • + ))} +
    + )} +
    + ); +} diff --git a/eval/reference/chain4/react/SettingsPage.tsx b/eval/reference/chain4/react/SettingsPage.tsx new file mode 100644 index 0000000..d4ad74e --- /dev/null +++ b/eval/reference/chain4/react/SettingsPage.tsx @@ -0,0 +1,76 @@ +import { useRef, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { loadSettings, saveSettings, type Density, type Settings, type Theme } from "./api"; + +export default function SettingsPage() { + const queryClient = useQueryClient(); + const settings = useQuery({ queryKey: ["settings"], queryFn: loadSettings }); + const [saveError, setSaveError] = useState(""); + // saves still in the air, oldest first; the newest one shows until it answers or a newer one is confirmed + const [saving, setSaving] = useState<{ seq: number; value: Settings }[]>([]); + const seqRef = useRef(0); + const confirmedRef = useRef(0); + + const save = async (next: Settings) => { + const seq = ++seqRef.current; + setSaveError(""); + setSaving(prev => [...prev, { seq, value: next }]); + try { + const saved = await saveSettings(next); + if (seq > confirmedRef.current) { + confirmedRef.current = seq; + queryClient.setQueryData(["settings"], saved); + } + } catch (e) { + setSaveError(e instanceof Error ? e.message : String(e)); + } finally { + setSaving(prev => prev.filter(p => p.seq !== seq)); + } + }; + + if (settings.isPending) return

    Loading settings…

    ; + if (settings.error) return ( +
    +

    Could not load settings: {settings.error.message}

    {" "} + +
    + ); + const guess = saving.filter(p => p.seq > confirmedRef.current).at(-1); + const s = guess?.value ?? settings.data; + return ( +
    +

    Saved: {s.name} · {s.theme} · {s.density}{guess ? " (saving…)" : ""}

    {" "} + {saveError &&

    Save failed: {saveError}

    } + void save(draft)} /> +
    + ); +} + +function nameError(name: string) { + if (name.length === 0) return "Display name is required"; + if (name.length > 20) return `Display name must be at most 20 characters, not ${name.length}`; + return ""; +} + +// Seeded once from the loaded settings; after that the draft is the user's. +function SettingsForm({ initial, onSave }: { initial: Settings; onSave: (draft: Settings) => void }) { + const [name, setName] = useState(initial.name); + const [theme, setTheme] = useState(initial.theme); + const [density, setDensity] = useState(initial.density); + const error = nameError(name); + return ( +
    + {" "} + {name.length}/20{" "} + {error &&

    {error}

    } +
    + Theme: {theme}{" "} + {" "} + {" "} + {" "} +

    Preview: {theme} / {density}

    +
    + +
    + ); +} diff --git a/eval/reference/chain4/react/errors.ts b/eval/reference/chain4/react/errors.ts new file mode 100644 index 0000000..c380616 --- /dev/null +++ b/eval/reference/chain4/react/errors.ts @@ -0,0 +1,4 @@ +export const errorMessage = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +/** an error that belongs to one issue: shown only while that issue is selected */ +export type IssueError = { issueId: number; message: string }; diff --git a/eval/reference/chain4/rx/comments.rx b/eval/reference/chain4/rx/comments.rx new file mode 100644 index 0000000..3e9b7b2 --- /dev/null +++ b/eval/reference/chain4/rx/comments.rx @@ -0,0 +1,55 @@ +// The selected issue's comments, and posting one. Posts in flight live in their own source, so changing the +// selection never waits for them, and each shows only under its own issue. + +import pure { fetchComments } from "./api" +import { addComment } from "./api" + +export component Comments(props: { id: number; user: string }) { + source comments = await fetchComments(props.id) + source sending: { id: number; author: string; text: string }[] = [] + shownSending = sending.filter(s => s.id == props.id) + source postError: string | null = null + source commentInput: string := "" + + // a post error belongs to the issue it was made on: selecting another clears it + effect (props.id) { postError := null } + + action postComment(id: number, author: string, text: string) { + optimistic postError := null + try { + optimistic sending := [...sending, { id: id, author: author, text: text }] + await addComment(id, author, text) + refresh comments + } catch (e) { + postError := e.message + } + } + +
    +
    + {loading on(props.id) fallback {

    loading comments

    } { + {errored fallback(e) {

    comments error: {e.message}

    } { +
      + {for c in comments { +
    • {c.author}: {c.text}
    • + }} + {for s in shownSending { +
    • {s.author}: {s.text} (sending…)
    • + }} +
    + }} + }} +
    + { commentInput := e.target.value }} /> + + {if postError != null { +

    post error: {postError}

    + }} +
    +} diff --git a/eval/reference/chain4/rx/help.rx b/eval/reference/chain4/rx/help.rx new file mode 100644 index 0000000..267e779 --- /dev/null +++ b/eval/reference/chain4/rx/help.rx @@ -0,0 +1,53 @@ +// The help page: search the help catalogue and open one result. + +import pure { search, fetchItem } from "./api" + +export error TooShort(q: string) => `too short: ${q}` +export error ItemNotFound(id: number) => `item ${id} not found` + +fn foundItem(it, id: number) => it == null ? throw ItemNotFound(id) : it + +// Opens on `query` with `selected` chosen (both empty from the tab; a related hit opens it on its issue's title). +export component HelpPage(props: { query: string; selected: number | null }) { + source query: string := peek(props.query) + source selected: number | null := peek(props.selected) + results = query == "" ? [] : query.length < 2 ? throw TooShort(query) : await search(query) + item = selected == null ? null : foundItem(await fetchItem(selected), selected) + +
    + { query := e.target.value }} /> + {if query != "" { +
    + {errored fallback(e) {

    search failed: {e.message}

    } { + errored fallback(e: TooShort) {

    {e.message}

    } { + loading fallback {

    searching…

    } { +

    {pending? results ? "searching…" : ""}

    +

    {results.length} results for {latest query}

    +
      + {for r in results { +
    • { selected := r.id }}>{r.id == selected ? "> " : ""}{r.name}
    • + }} +
    + } + } + }} +
    + }} + {if selected == null { +

    pick a result

    + } else { +
    + {loading on(selected) fallback {

    loading…

    } { + {for id in [selected] { + {errored fallback(e) {

    failed: {e.message}

    } { + {errored fallback(e: ItemNotFound) {

    {e.message}

    } { +

    {item.name}

    +

    {item.blurb}

    + }} + }} + }} + }} +
    + }} +
    +} diff --git a/eval/reference/chain4/rx/issues.rx b/eval/reference/chain4/rx/issues.rx new file mode 100644 index 0000000..23aab1a --- /dev/null +++ b/eval/reference/chain4/rx/issues.rx @@ -0,0 +1,160 @@ +// The issues page: the filtered list, the selected issue's detail, and the writes on it (assign, rename, close). + +import pure { fetchIssues, fetchIssue } from "./api" +import { assign, renameIssue, closeIssue } from "./api" +import { Comments } from "./comments.rx" +import { Labels } from "./labels.rx" +import { Related } from "./related.rx" + +export error NotFound(id: number) => `issue #${id} not found` + +fn found(it, id: number) => it == null ? throw NotFound(id) : it + +export component IssuesPage(props: { onOpenCount: (n: number) => void; onOpenHelp: (query: string, id: number) => void }) { + source user: string := "guest" + source filter: string := "open" + source selected: number | null := null + source issues key id = await fetchIssues(filter) + source detail = selected == null ? null : found(await fetchIssue(selected), selected) + source titleInput: string := "" + source assignError: string = "" + source renameError: string = "" + source closeError: string = "" + // the newest row action's failure; row actions and the detail's leave each other's errors alone + source rowError: string = "" + + // the open list as shown (closes in flight already out of it) is the tab's count; other lists, loads and failures + // leave the count as it was + effect ((filter == "open" ? issues.length : -1) catch (e) => -1) { + if ($0 >= 0) { + props.onOpenCount($0) + } + } + + action assignTo(id: number, who: string, fromRow: boolean) { + if (fromRow) { + optimistic rowError := "" + } else { + optimistic assignError := "" + } + try { + let i = issues.findIndex(t => t.id == id) + optimistic issues[i].assignee := who + await assign(id, who) + refresh issues + } catch (e) { + if (fromRow) { + rowError := e.message + } else { + assignError := e.message + } + } + } + + action renameTo(id: number, title: string) { + optimistic renameError := "" + try { + let i = issues.findIndex(t => t.id == id) + if (i >= 0) { + optimistic issues[i].title := title + } + if (detail != null && detail.id == id) { + optimistic detail.title := title + } + await renameIssue(id, title) + refresh issues + refresh detail + } catch (e) { + renameError := e.message + } + } + + action closeOne(id: number, fromRow: boolean) { + if (fromRow) { + optimistic rowError := "" + } else { + optimistic closeError := "" + } + try { + optimistic issues := issues.filter(t => t.id != id) + await closeIssue(id) + refresh issues + } catch (e) { + if (fromRow) { + rowError := e.message + } else { + closeError := e.message + } + } + } + +
    +
    + +
    +
    + + +
    + {if rowError != "" { +

    row error: {rowError}

    + }} +
    + {loading on(filter) fallback {

    loading {latest filter} issues

    } { + {errored fallback(e) {

    list error: {e.message}

    } { +
      + {for it in issues { +
    • + { + selected := it.id + titleInput := "" + onFlush(() => { + assignError := "" + renameError := "" + closeError := "" + }) + }}>#{it.id} {it.title} ({it.assignee ?? "unassigned"}){" "} + + {if filter == "open" { + {" "} + }} +
    • + }} +
    + }} + }} +
    +
    + {if selected != null { + {loading on(selected) fallback {

    loading #{latest selected}

    } { + {errored fallback(e) {

    detail error: {e.message}

    } { +

    #{detail.id} {detail.title}

    +

    {detail.body}

    + { props.onOpenHelp(q, id) }} /> + }} + + }} + + {if assignError != "" { +

    {assignError}

    + }} + { titleInput := e.target.value }} /> + + {if renameError != "" { +

    rename error: {renameError}

    + }} + + {if closeError != "" { +

    close error: {closeError}

    + }} + + + } else { +

    select an issue

    + }} +
    +
    +} diff --git a/eval/reference/chain4/rx/labels.rx b/eval/reference/chain4/rx/labels.rx new file mode 100644 index 0000000..052b752 --- /dev/null +++ b/eval/reference/chain4/rx/labels.rx @@ -0,0 +1,67 @@ +// The selected issue's labels, adding and removing them. Changes in flight live in their own sources, so changing +// the selection never waits for them, and each shows only under its own issue. + +import pure { fetchLabels } from "./api" +import { addLabel, removeLabel } from "./api" + +export component Labels(props: { id: number }) { + source labels = await fetchLabels(props.id) + source adding: { id: number; label: string }[] = [] + source removing: { id: number; label: string }[] = [] + shown = [ + ...labels.filter(l => !removing.some(r => r.id == props.id && r.label == l)), + ...adding.filter(a => a.id == props.id).map(a => a.label), + ] + source labelError: string = "" + source labelInput: string := "" + + // a label error belongs to the issue it was made on: selecting another clears it + effect (props.id) { labelError := "" } + + action add(id: number, label: string) { + optimistic labelError := "" + try { + optimistic adding := [...adding, { id: id, label: label }] + await addLabel(id, label) + refresh labels + } catch (e) { + labelError := e.message + } + } + + action remove(id: number, label: string) { + optimistic labelError := "" + try { + optimistic removing := [...removing, { id: id, label: label }] + await removeLabel(id, label) + refresh labels + } catch (e) { + labelError := e.message + } + } + +
    +
    + {loading on(props.id) fallback {

    loading labels

    } { + {errored fallback(e) {

    labels error: {e.message}

    } { +
      + {for l in shown { +
    • {l}{" "}
    • + }} +
    + }} + }} +
    + { labelInput := e.target.value }} /> + + {if labelError != "" { +

    label error: {labelError}

    + }} +
    +} diff --git a/eval/reference/chain4/rx/main.rx b/eval/reference/chain4/rx/main.rx new file mode 100644 index 0000000..33b1881 --- /dev/null +++ b/eval/reference/chain4/rx/main.rx @@ -0,0 +1,37 @@ +// The desk: three pages behind tabs. Only the open page is mounted, so leaving one discards its state. + +import { IssuesPage } from "./issues.rx" +import { HelpPage } from "./help.rx" +import { SettingsPage } from "./settings.rx" + +export default component App() { + source page: string := "issues" + source openCount: number | null := null + source helpQuery: string := "" + source helpSelected: number | null := null + +
    + + {if page == "issues" { + { openCount := n }} onOpenHelp={(q, id) => { + helpQuery := q + helpSelected := id + page := "help" + }} /> + } else { + {if page == "help" { + + } else { + + }} + }} +
    +} diff --git a/eval/reference/chain4/rx/related.rx b/eval/reference/chain4/rx/related.rx new file mode 100644 index 0000000..aea6011 --- /dev/null +++ b/eval/reference/chain4/rx/related.rx @@ -0,0 +1,23 @@ +// Help related to an issue: the first hits of a search for its title. + +import pure { search } from "./api" + +export component Related(props: { title: string; onOpen: (query: string, id: number) => void }) { + hits = await search(props.title) + +
    + {loading on(props.title) fallback {

    finding related help

    } { + {errored fallback(e) {

    related help failed: {e.message}

    } { + {if hits.length == 0 { +

    no related help

    + } else { +
      + {for h in hits.slice(0, 3) { +
    • + }} +
    + }} + }} + }} +
    +} diff --git a/eval/reference/chain4/rx/settings.rx b/eval/reference/chain4/rx/settings.rx new file mode 100644 index 0000000..0df8231 --- /dev/null +++ b/eval/reference/chain4/rx/settings.rx @@ -0,0 +1,61 @@ +// The settings page: the saved settings, a draft form seeded from them, and saving. + +import pure { loadSettings } from "./api" +import { saveSettings, Settings } from "./api" + +export error NameRequired() => "Display name is required" +export error NameTooLong(count: number) => `Display name must be at most 20 characters, not ${count}` + +fn validName(name: string): string => + name.length == 0 ? throw NameRequired() + : name.length > 20 ? throw NameTooLong(name.length) + : name + +export component SettingsPage() { + source settings: Settings = await loadSettings() + source saveError: string = "" + + action save(next: Settings) { + optimistic settings := next + optimistic saveError := "" + affects settings + try { + let saved = await saveSettings(next) + settings := saved + } catch (e) { + saveError := e.message + } + } + + errored fallback(e) { +

    Could not load settings: {e.message}

    + + } { + loading fallback {

    Loading settings…

    } { + source draftName: string := peek(settings.name) + source draftTheme: string := peek(settings.theme) + source draftDensity: string := peek(settings.density) + nameError: string = (validName(draftName) && "") catch (e: NameRequired | NameTooLong) => e.message +

    Saved: {settings.name} · {settings.theme} · {settings.density}{pending? settings ? " (saving…)" : ""}

    + {if saveError != "" { +

    Save failed: {saveError}

    + }} + {" "} + {draftName.length}/20 + {if nameError != "" { +

    {nameError}

    + }} +
    + {" "} + {" "} + +
    +

    Preview: {draftTheme} / {draftDensity}

    + + } + } +} diff --git a/eval/reference/count/foldkit/help.ts b/eval/reference/count/foldkit/help.ts new file mode 100644 index 0000000..b9f086d --- /dev/null +++ b/eval/reference/count/foldkit/help.ts @@ -0,0 +1,213 @@ +import { Array, Effect, Option, Schema } from 'effect' +import { Command, Submodel, type Update } from 'foldkit' +import { Html, HtmlBuilder } from 'foldkit/html' +import { defineMessageUnion } from 'foldkit/message' +import { defineTaggedUnion } from 'foldkit/schema' +import { modifyFields } from 'foldkit/struct' + +import * as Api from './api' +import { request } from './request' + +// MODEL + +const Hit = Schema.Struct({ id: Schema.Number, name: Schema.String }) +const Item = Schema.Struct({ id: Schema.Number, name: Schema.String, blurb: Schema.String }) + +// The results on screen stay until a newer query's answer replaces them. +const Shown = Schema.Struct({ query: Schema.String, hits: Schema.Array(Hit) }) + +export const ItemState = defineTaggedUnion({ + Loading: {}, + Loaded: { maybeItem: Schema.Option(Item) }, + Failed: { error: Schema.String }, +}) + +export const Model = Schema.Struct({ + query: Schema.String, + searchSeq: Schema.Number, + isSearching: Schema.Boolean, + maybeShown: Schema.Option(Shown), + maybeSearchError: Schema.Option(Schema.String), + maybeSelected: Schema.Option(Schema.Number), + itemSeq: Schema.Number, + item: ItemState, +}) +export type Model = typeof Model.Type + +// MESSAGE + +export const Message = defineMessageUnion({ + UpdatedQuery: { value: Schema.String }, + SucceededSearch: { seq: Schema.Number, query: Schema.String, hits: Schema.Array(Hit) }, + FailedSearch: { seq: Schema.Number, error: Schema.String }, + ClickedHit: { id: Schema.Number }, + SucceededFetchItem: { seq: Schema.Number, item: Schema.NullOr(Item) }, + FailedFetchItem: { seq: Schema.Number, error: Schema.String }, + ClickedRetryItem: {}, +}) +export type Message = typeof Message.Type + +// COMMAND + +const Search = Command.define('Search', { + args: { seq: Schema.Number, query: Schema.String }, + messages: [Message.SucceededSearch, Message.FailedSearch], + execute: ({ seq, query }) => + request(() => Api.search(query)).pipe( + Effect.map(hits => Message.SucceededSearch({ seq, query, hits })), + Effect.catch(error => Effect.succeed(Message.FailedSearch({ seq, error }))), + ), +}) + +const FetchItem = Command.define('FetchItem', { + args: { seq: Schema.Number, id: Schema.Number }, + messages: [Message.SucceededFetchItem, Message.FailedFetchItem], + execute: ({ seq, id }) => + request(() => Api.fetchItem(id)).pipe( + Effect.map(item => Message.SucceededFetchItem({ seq, item })), + Effect.catch(error => Effect.succeed(Message.FailedFetchItem({ seq, error }))), + ), +}) + +// INIT + +export const init = (): Update.Return => ({ + model: { + query: '', + searchSeq: 0, + isSearching: false, + maybeShown: Option.none(), + maybeSearchError: Option.none(), + maybeSelected: Option.none(), + itemSeq: 0, + item: ItemState.Loading(), + }, +}) + +// UPDATE + +export const update = (model: Model, message: Message) => + Message.match>(message, { + UpdatedQuery: ({ value }) => { + const searchSeq = model.searchSeq + 1 + const isAsked = value.length >= 2 + const next = modifyFields(model, { + query: () => value, + searchSeq: () => searchSeq, + isSearching: () => isAsked, + maybeSearchError: () => Option.none(), + maybeShown: shown => (value === '' ? Option.none() : shown), + }) + return isAsked ? { model: next, commands: [Search({ seq: searchSeq, query: value })] } : { model: next } + }, + + SucceededSearch: ({ seq, query, hits }) => + seq === model.searchSeq + ? { + model: modifyFields(model, { + isSearching: () => false, + maybeShown: () => Option.some({ query, hits }), + }), + } + : { model }, + + FailedSearch: ({ seq, error }) => + seq === model.searchSeq + ? { model: modifyFields(model, { isSearching: () => false, maybeSearchError: () => Option.some(error) }) } + : { model }, + + ClickedHit: ({ id }) => { + if (Option.contains(model.maybeSelected, id)) { + return { model } + } + const itemSeq = model.itemSeq + 1 + return { + model: modifyFields(model, { + maybeSelected: () => Option.some(id), + itemSeq: () => itemSeq, + item: () => ItemState.Loading(), + }), + commands: [FetchItem({ seq: itemSeq, id })], + } + }, + + ClickedRetryItem: () => + Option.match(model.maybeSelected, { + onNone: () => ({ model }), + onSome: id => { + const itemSeq = model.itemSeq + 1 + return { model: modifyFields(model, { itemSeq: () => itemSeq }), commands: [FetchItem({ seq: itemSeq, id })] } + }, + }), + + SucceededFetchItem: ({ seq, item }) => + seq === model.itemSeq + ? { model: modifyFields(model, { item: () => ItemState.Loaded({ maybeItem: Option.fromNullishOr(item) }) }) } + : { model }, + + FailedFetchItem: ({ seq, error }) => + seq === model.itemSeq ? { model: modifyFields(model, { item: () => ItemState.Failed({ error }) }) } : { model }, + }) + +// VIEW + +const testId = (h: HtmlBuilder, id: string) => h.DataAttribute('testid', id) + +const resultsView = (model: Model, h: HtmlBuilder): Html => { + if (model.query.length < 2) { + return h.section([testId(h, 'results')], [h.p([], [`too short: ${model.query}`])]) + } + const shown = Option.match(model.maybeSearchError, { + onSome: error => (model.isSearching ? h.empty : h.p([], [`search failed: ${error}`])), + onNone: () => + Option.match(model.maybeShown, { + onNone: () => h.empty, + onSome: ({ query, hits }) => + h.div([], [ + h.h2([], [`${hits.length} results for ${query}`]), + h.ul( + [], + Array.map(hits, hit => + h.keyed('li')( + String(hit.id), + [testId(h, 'hit'), h.OnClick(Message.ClickedHit({ id: hit.id }))], + [`${Option.contains(model.maybeSelected, hit.id) ? '> ' : ''}${hit.name}`], + ), + ), + ), + ]), + }), + }) + return h.section([testId(h, 'results')], [model.isSearching ? h.p([], ['searching…']) : h.empty, shown]) +} + +const itemView = (model: Model, id: number, h: HtmlBuilder): Html => + h.section( + [testId(h, 'item')], + [ + ItemState.match(model.item, { + Loading: () => h.p([], ['loading…']), + Failed: ({ error }) => + h.div([], [ + h.p([], [`failed: ${error}`]), + h.button([testId(h, 'retry-item'), h.OnClick(Message.ClickedRetryItem())], ['retry']), + ]), + Loaded: ({ maybeItem }) => + Option.match(maybeItem, { + onNone: () => h.p([], [`item ${id} not found`]), + onSome: item => h.article([], [h.h3([], [item.name]), h.p([], [item.blurb])]), + }), + }), + ], + ) + +export const view = Submodel.defineView((model, h) => + h.main([], [ + h.input([testId(h, 'query'), h.Value(model.query), h.OnInput(value => Message.UpdatedQuery({ value }))]), + model.query === '' ? h.empty : resultsView(model, h), + Option.match(model.maybeSelected, { + onNone: () => h.p([testId(h, 'item')], ['pick a result']), + onSome: id => itemView(model, id, h), + }), + ]), +) diff --git a/eval/reference/count/foldkit/issues.ts b/eval/reference/count/foldkit/issues.ts new file mode 100644 index 0000000..8ee3143 --- /dev/null +++ b/eval/reference/count/foldkit/issues.ts @@ -0,0 +1,602 @@ +import { Array, Effect, Option, Schema, pipe } from 'effect' +import { Command, Submodel, type Update } from 'foldkit' +import { Html, HtmlBuilder } from 'foldkit/html' +import { defineMessageUnion } from 'foldkit/message' +import { defineTaggedUnion } from 'foldkit/schema' +import { modifyFields } from 'foldkit/struct' + +import * as Api from './api' +import { request } from './request' + +// MODEL + +const Filter = Schema.Literals(['open', 'closed']) +type Filter = typeof Filter.Type + +const Issue = Schema.Struct({ id: Schema.Number, title: Schema.String, assignee: Schema.NullOr(Schema.String) }) +type Issue = typeof Issue.Type +const Detail = Schema.Struct({ id: Schema.Number, title: Schema.String, body: Schema.String }) +const Comment = Schema.Struct({ author: Schema.String, text: Schema.String }) + +export const ListState = defineTaggedUnion({ + Loading: {}, + Loaded: { issues: Schema.Array(Issue) }, + Failed: { error: Schema.String }, +}) +export const DetailState = defineTaggedUnion({ + Loading: {}, + Loaded: { detail: Detail }, + Failed: { error: Schema.String }, +}) +export const CommentsState = defineTaggedUnion({ + Loading: {}, + Loaded: { comments: Schema.Array(Comment) }, + Failed: { error: Schema.String }, +}) + +// A write still in the air. `seq` orders writes: the newest guess for an issue is what it shows. +const Guess = Schema.Struct({ seq: Schema.Number, id: Schema.Number, value: Schema.String }) +type Guess = typeof Guess.Type +const Sending = Schema.Struct({ seq: Schema.Number, id: Schema.Number, author: Schema.String, text: Schema.String }) +// An error that belongs to one issue: shown only while that issue is selected. +const IssueError = Schema.Struct({ id: Schema.Number, message: Schema.String }) +type IssueError = typeof IssueError.Type +// The newest write of a kind that the server confirmed, per issue. +const Confirmed = Schema.Struct({ id: Schema.Number, seq: Schema.Number }) +type Confirmed = typeof Confirmed.Type + +export const Model = Schema.Struct({ + user: Schema.String, + filter: Filter, + listSeq: Schema.Number, + list: ListState, + maybeSelected: Schema.Option(Schema.Number), + detailSeq: Schema.Number, + detail: DetailState, + comments: CommentsState, + seq: Schema.Number, + assigning: Schema.Array(Guess), + confirmedAssigns: Schema.Array(Confirmed), + lastAssign: Schema.Number, + maybeAssignError: Schema.Option(IssueError), + titleInput: Schema.String, + renaming: Schema.Array(Guess), + confirmedRenames: Schema.Array(Confirmed), + lastRename: Schema.Number, + maybeRenameError: Schema.Option(IssueError), + closing: Schema.Array(Schema.Struct({ seq: Schema.Number, id: Schema.Number })), + lastClose: Schema.Number, + maybeCloseError: Schema.Option(IssueError), + commentInput: Schema.String, + sending: Schema.Array(Sending), + lastPost: Schema.Number, + maybePostError: Schema.Option(IssueError), +}) +export type Model = typeof Model.Type + +// MESSAGE + +export const Message = defineMessageUnion({ + UpdatedUser: { value: Schema.String }, + ClickedFilter: { filter: Filter }, + SucceededFetchIssues: { seq: Schema.Number, issues: Schema.Array(Issue) }, + FailedFetchIssues: { seq: Schema.Number, error: Schema.String }, + ClickedIssue: { id: Schema.Number }, + ClickedRetry: {}, + SucceededFetchIssue: { seq: Schema.Number, detail: Schema.NullOr(Detail) }, + FailedFetchIssue: { seq: Schema.Number, error: Schema.String }, + SucceededFetchComments: { seq: Schema.Number, comments: Schema.Array(Comment) }, + FailedFetchComments: { seq: Schema.Number, error: Schema.String }, + ClickedAssign: {}, + SucceededAssign: { seq: Schema.Number, id: Schema.Number, assignee: Schema.String }, + FailedAssign: { seq: Schema.Number, id: Schema.Number, error: Schema.String }, + UpdatedTitleInput: { value: Schema.String }, + ClickedRename: {}, + SucceededRename: { seq: Schema.Number, id: Schema.Number, title: Schema.String }, + FailedRename: { seq: Schema.Number, id: Schema.Number, error: Schema.String }, + ClickedClose: {}, + SucceededClose: { seq: Schema.Number, id: Schema.Number }, + FailedClose: { seq: Schema.Number, id: Schema.Number, error: Schema.String }, + UpdatedCommentInput: { value: Schema.String }, + ClickedPost: {}, + SucceededPost: { seq: Schema.Number, id: Schema.Number, comment: Comment }, + FailedPost: { seq: Schema.Number, id: Schema.Number, error: Schema.String }, +}) +export type Message = typeof Message.Type + +// COMMAND + +const FetchIssues = Command.define('FetchIssues', { + args: { seq: Schema.Number, filter: Filter }, + messages: [Message.SucceededFetchIssues, Message.FailedFetchIssues], + execute: ({ seq, filter }) => + request(() => Api.fetchIssues(filter)).pipe( + Effect.map(issues => Message.SucceededFetchIssues({ seq, issues })), + Effect.catch(error => Effect.succeed(Message.FailedFetchIssues({ seq, error }))), + ), +}) + +const FetchIssue = Command.define('FetchIssue', { + args: { seq: Schema.Number, id: Schema.Number }, + messages: [Message.SucceededFetchIssue, Message.FailedFetchIssue], + execute: ({ seq, id }) => + request(() => Api.fetchIssue(id)).pipe( + Effect.map(detail => Message.SucceededFetchIssue({ seq, detail })), + Effect.catch(error => Effect.succeed(Message.FailedFetchIssue({ seq, error }))), + ), +}) + +const FetchComments = Command.define('FetchComments', { + args: { seq: Schema.Number, id: Schema.Number }, + messages: [Message.SucceededFetchComments, Message.FailedFetchComments], + execute: ({ seq, id }) => + request(() => Api.fetchComments(id)).pipe( + Effect.map(comments => Message.SucceededFetchComments({ seq, comments })), + Effect.catch(error => Effect.succeed(Message.FailedFetchComments({ seq, error }))), + ), +}) + +const Assign = Command.define('Assign', { + args: { seq: Schema.Number, id: Schema.Number, user: Schema.String }, + messages: [Message.SucceededAssign, Message.FailedAssign], + execute: ({ seq, id, user }) => + request(() => Api.assign(id, user)).pipe( + Effect.map(answer => Message.SucceededAssign({ seq, id, assignee: answer.assignee })), + Effect.catch(error => Effect.succeed(Message.FailedAssign({ seq, id, error }))), + ), +}) + +const Rename = Command.define('Rename', { + args: { seq: Schema.Number, id: Schema.Number, title: Schema.String }, + messages: [Message.SucceededRename, Message.FailedRename], + execute: ({ seq, id, title }) => + request(() => Api.renameIssue(id, title)).pipe( + Effect.map(answer => Message.SucceededRename({ seq, id, title: answer.title })), + Effect.catch(error => Effect.succeed(Message.FailedRename({ seq, id, error }))), + ), +}) + +const Close = Command.define('Close', { + args: { seq: Schema.Number, id: Schema.Number }, + messages: [Message.SucceededClose, Message.FailedClose], + execute: ({ seq, id }) => + request(() => Api.closeIssue(id)).pipe( + Effect.as(Message.SucceededClose({ seq, id })), + Effect.catch(error => Effect.succeed(Message.FailedClose({ seq, id, error }))), + ), +}) + +const Post = Command.define('Post', { + args: { seq: Schema.Number, id: Schema.Number, author: Schema.String, text: Schema.String }, + messages: [Message.SucceededPost, Message.FailedPost], + execute: ({ seq, id, author, text }) => + request(() => Api.addComment(id, author, text)).pipe( + Effect.map(comment => Message.SucceededPost({ seq, id, comment })), + Effect.catch(error => Effect.succeed(Message.FailedPost({ seq, id, error }))), + ), +}) + +// INIT + +export const init = (): Update.Return => ({ + model: { + user: 'guest', + filter: 'open', + listSeq: 1, + list: ListState.Loading(), + maybeSelected: Option.none(), + detailSeq: 0, + detail: DetailState.Loading(), + comments: CommentsState.Loading(), + seq: 1, + assigning: [], + confirmedAssigns: [], + lastAssign: 0, + maybeAssignError: Option.none(), + titleInput: '', + renaming: [], + confirmedRenames: [], + lastRename: 0, + maybeRenameError: Option.none(), + closing: [], + lastClose: 0, + maybeCloseError: Option.none(), + commentInput: '', + sending: [], + lastPost: 0, + maybePostError: Option.none(), + }, + commands: [FetchIssues({ seq: 1, filter: 'open' })], +}) + +// UPDATE + +const dropGuess = + (seq: number) => + (guesses: ReadonlyArray): ReadonlyArray => + Array.filter(guesses, guess => guess.seq !== seq) + +const latestGuess = (guesses: ReadonlyArray, id: number): Option.Option => + pipe( + guesses, + Array.filter(guess => guess.id === id), + Array.last, + Option.map(guess => guess.value), + ) + +const confirmedSeq = (confirmed: ReadonlyArray, id: number): number => + pipe( + confirmed, + Array.findFirst(entry => entry.id === id), + Option.match({ onNone: () => 0, onSome: entry => entry.seq }), + ) + +const confirm = (confirmed: ReadonlyArray, id: number, seq: number): ReadonlyArray => [ + ...Array.filter(confirmed, entry => entry.id !== id), + { id, seq }, +] + +const mapIssue = (id: number, f: (issue: Issue) => Issue) => (list: typeof ListState.Type) => + ListState.match(list, { + Loading: () => list, + Failed: () => list, + Loaded: ({ issues }) => ListState.Loaded({ issues: Array.map(issues, issue => (issue.id === id ? f(issue) : issue)) }), + }) + +const isSelected = (model: Model, id: number) => Option.contains(model.maybeSelected, id) + +// Only the newest write of a kind reports its failure. +const failure = (last: number, seq: number, id: number, message: string) => (current: Option.Option) => + last === seq ? Option.some({ id, message }) : current + +export const update = (model: Model, message: Message) => + Message.match>(message, { + UpdatedUser: ({ value }) => ({ model: modifyFields(model, { user: () => value }) }), + + ClickedFilter: ({ filter }) => { + if (filter === model.filter) { + return { model } + } + const listSeq = model.listSeq + 1 + return { + model: modifyFields(model, { filter: () => filter, listSeq: () => listSeq, list: () => ListState.Loading() }), + commands: [FetchIssues({ seq: listSeq, filter })], + } + }, + + SucceededFetchIssues: ({ seq, issues }) => + seq === model.listSeq ? { model: modifyFields(model, { list: () => ListState.Loaded({ issues }) }) } : { model }, + + FailedFetchIssues: ({ seq, error }) => + seq === model.listSeq ? { model: modifyFields(model, { list: () => ListState.Failed({ error }) }) } : { model }, + + ClickedIssue: ({ id }) => { + const cleared = modifyFields(model, { + titleInput: () => '', + maybeAssignError: () => Option.none(), + maybeRenameError: () => Option.none(), + maybeCloseError: () => Option.none(), + maybePostError: () => Option.none(), + }) + if (isSelected(model, id)) { + return { model: cleared } + } + const detailSeq = model.detailSeq + 1 + return { + model: modifyFields(cleared, { + maybeSelected: () => Option.some(id), + detailSeq: () => detailSeq, + detail: () => DetailState.Loading(), + comments: () => CommentsState.Loading(), + }), + commands: [FetchIssue({ seq: detailSeq, id }), FetchComments({ seq: detailSeq, id })], + } + }, + + ClickedRetry: () => + Option.match(model.maybeSelected, { + onNone: () => ({ model }), + onSome: id => { + const detailSeq = model.detailSeq + 1 + return { + model: modifyFields(model, { detailSeq: () => detailSeq, detail: () => DetailState.Loading() }), + commands: [FetchIssue({ seq: detailSeq, id })], + } + }, + }), + + SucceededFetchIssue: ({ seq, detail }) => { + if (seq !== model.detailSeq) { + return { model } + } + const id = Option.getOrElse(model.maybeSelected, () => 0) + return { + model: modifyFields(model, { + detail: () => + detail === null ? DetailState.Failed({ error: `issue #${id} not found` }) : DetailState.Loaded({ detail }), + }), + } + }, + + FailedFetchIssue: ({ seq, error }) => + seq === model.detailSeq ? { model: modifyFields(model, { detail: () => DetailState.Failed({ error }) }) } : { model }, + + SucceededFetchComments: ({ seq, comments }) => + seq === model.detailSeq + ? { model: modifyFields(model, { comments: () => CommentsState.Loaded({ comments }) }) } + : { model }, + + FailedFetchComments: ({ seq, error }) => + seq === model.detailSeq ? { model: modifyFields(model, { comments: () => CommentsState.Failed({ error }) }) } : { model }, + + ClickedAssign: () => + Option.match(model.maybeSelected, { + onNone: () => ({ model }), + onSome: id => ({ + model: modifyFields(model, { + seq: seq => seq + 1, + lastAssign: () => model.seq, + maybeAssignError: () => Option.none(), + assigning: Array.append({ seq: model.seq, id, value: model.user }), + }), + commands: [Assign({ seq: model.seq, id, user: model.user })], + }), + }), + + SucceededAssign: ({ seq, id, assignee }) => { + const isNewest = confirmedSeq(model.confirmedAssigns, id) < seq + return { + model: modifyFields(model, { + assigning: dropGuess(seq), + confirmedAssigns: confirmed => (isNewest ? confirm(confirmed, id, seq) : confirmed), + list: list => (isNewest ? mapIssue(id, issue => ({ ...issue, assignee }))(list) : list), + }), + } + }, + + FailedAssign: ({ seq, id, error }) => ({ + model: modifyFields(model, { + assigning: dropGuess(seq), + maybeAssignError: failure(model.lastAssign, seq, id, error), + }), + }), + + UpdatedTitleInput: ({ value }) => ({ model: modifyFields(model, { titleInput: () => value }) }), + + ClickedRename: () => + Option.match(model.maybeSelected, { + onNone: () => ({ model }), + onSome: id => + model.titleInput === '' + ? { model } + : { + model: modifyFields(model, { + seq: seq => seq + 1, + lastRename: () => model.seq, + maybeRenameError: () => Option.none(), + renaming: Array.append({ seq: model.seq, id, value: model.titleInput }), + }), + commands: [Rename({ seq: model.seq, id, title: model.titleInput })], + }, + }), + + SucceededRename: ({ seq, id, title }) => { + const isNewest = confirmedSeq(model.confirmedRenames, id) < seq + return { + model: modifyFields(model, { + renaming: dropGuess(seq), + confirmedRenames: confirmed => (isNewest ? confirm(confirmed, id, seq) : confirmed), + list: list => (isNewest ? mapIssue(id, issue => ({ ...issue, title }))(list) : list), + detail: detail => + isNewest && detail._tag === 'Loaded' && detail.detail.id === id + ? DetailState.Loaded({ detail: { ...detail.detail, title } }) + : detail, + }), + } + }, + + FailedRename: ({ seq, id, error }) => ({ + model: modifyFields(model, { + renaming: dropGuess(seq), + maybeRenameError: failure(model.lastRename, seq, id, error), + }), + }), + + ClickedClose: () => + Option.match(model.maybeSelected, { + onNone: () => ({ model }), + onSome: id => ({ + model: modifyFields(model, { + seq: seq => seq + 1, + lastClose: () => model.seq, + maybeCloseError: () => Option.none(), + closing: Array.append({ seq: model.seq, id }), + }), + commands: [Close({ seq: model.seq, id })], + }), + }), + + SucceededClose: ({ seq, id }) => ({ + model: modifyFields(model, { + closing: dropGuess(seq), + list: list => + model.filter === 'open' && list._tag === 'Loaded' + ? ListState.Loaded({ issues: Array.filter(list.issues, issue => issue.id !== id) }) + : list, + }), + }), + + FailedClose: ({ seq, id, error }) => ({ + model: modifyFields(model, { + closing: dropGuess(seq), + maybeCloseError: failure(model.lastClose, seq, id, error), + }), + }), + + UpdatedCommentInput: ({ value }) => ({ model: modifyFields(model, { commentInput: () => value }) }), + + ClickedPost: () => + Option.match(model.maybeSelected, { + onNone: () => ({ model }), + onSome: id => + model.commentInput === '' + ? { model } + : { + model: modifyFields(model, { + seq: seq => seq + 1, + lastPost: () => model.seq, + maybePostError: () => Option.none(), + commentInput: () => '', + sending: Array.append({ seq: model.seq, id, author: model.user, text: model.commentInput }), + }), + commands: [Post({ seq: model.seq, id, author: model.user, text: model.commentInput })], + }, + }), + + SucceededPost: ({ seq, id, comment }) => ({ + model: modifyFields(model, { + sending: dropGuess(seq), + comments: comments => + isSelected(model, id) && comments._tag === 'Loaded' + ? CommentsState.Loaded({ comments: Array.append(comments.comments, comment) }) + : comments, + }), + }), + + FailedPost: ({ seq, id, error }) => ({ + model: modifyFields(model, { + sending: dropGuess(seq), + maybePostError: failure(model.lastPost, seq, id, error), + }), + }), + }) + +// VIEW + +const testId = (h: HtmlBuilder, id: string) => h.DataAttribute('testid', id) + +const shownIssues = (model: Model, issues: ReadonlyArray): ReadonlyArray => + pipe( + issues, + Array.filter(issue => !(model.filter === 'open' && Array.some(model.closing, closing => closing.id === issue.id))), + Array.map(issue => ({ + ...issue, + title: Option.getOrElse(latestGuess(model.renaming, issue.id), () => issue.title), + assignee: Option.getOrElse(latestGuess(model.assigning, issue.id), () => issue.assignee), + })), + ) + +// The open list as shown (closes in flight already out of it). Other lists, loads and failures have no count. +export const openCount = (model: Model): Option.Option => + model.filter === 'open' && model.list._tag === 'Loaded' + ? Option.some(shownIssues(model, model.list.issues).length) + : Option.none() + +const listView = (model: Model, h: HtmlBuilder): Html => + ListState.match(model.list, { + Loading: () => h.p([], [`loading ${model.filter} issues`]), + Failed: ({ error }) => h.p([], [`list error: ${error}`]), + Loaded: ({ issues }) => + h.ul( + [], + Array.map(shownIssues(model, issues), issue => + h.keyed('li')(String(issue.id), [], [ + h.button( + [testId(h, 'issue'), h.OnClick(Message.ClickedIssue({ id: issue.id }))], + [`#${issue.id} ${issue.title} (${issue.assignee ?? 'unassigned'})`], + ), + ]), + ), + ), + }) + +const errorLine = (model: Model, maybeError: Option.Option, prefix: string, h: HtmlBuilder): Html => + pipe( + maybeError, + Option.filter(error => isSelected(model, error.id)), + Option.match({ onNone: () => h.empty, onSome: error => h.p([], [`${prefix}${error.message}`]) }), + ) + +const assignErrorLine = (model: Model, h: HtmlBuilder): Html => + pipe( + model.maybeAssignError, + Option.filter(error => isSelected(model, error.id)), + Option.match({ onNone: () => h.empty, onSome: error => h.p([testId(h, 'assign-error')], [error.message]) }), + ) + +const commentsView = (model: Model, id: number, h: HtmlBuilder): Html => + CommentsState.match(model.comments, { + Loading: () => h.p([], ['loading comments']), + Failed: ({ error }) => h.p([], [`comments error: ${error}`]), + Loaded: ({ comments }) => + h.ul([], [ + ...Array.map(comments, comment => h.li([testId(h, 'comment')], [`${comment.author}: ${comment.text}`])), + ...pipe( + model.sending, + Array.filter(sending => sending.id === id), + Array.map(sending => + h.keyed('li')(`s${sending.seq}`, [testId(h, 'comment')], [`${sending.author}: ${sending.text} (sending…)`]), + ), + ), + ]), + }) + +const detailBody = (model: Model, id: number, h: HtmlBuilder): Html => + DetailState.match(model.detail, { + Loading: () => h.p([], [`loading #${id}`]), + Failed: ({ error }) => h.p([], [`detail error: ${error}`]), + Loaded: ({ detail }) => + h.div([], [ + h.h2([], [`#${detail.id} ${Option.getOrElse(latestGuess(model.renaming, detail.id), () => detail.title)}`]), + h.p([], [detail.body]), + ]), + }) + +const detailView = (model: Model, id: number, h: HtmlBuilder): ReadonlyArray => [ + detailBody(model, id, h), + h.button([testId(h, 'retry'), h.OnClick(Message.ClickedRetry())], ['retry']), + h.button([testId(h, 'assign'), h.OnClick(Message.ClickedAssign())], [`assign to ${model.user}`]), + assignErrorLine(model, h), + h.div([], [ + h.input([ + testId(h, 'title-input'), + h.Value(model.titleInput), + h.OnInput(value => Message.UpdatedTitleInput({ value })), + ]), + h.button([testId(h, 'rename'), h.OnClick(Message.ClickedRename())], ['rename']), + errorLine(model, model.maybeRenameError, 'rename error: ', h), + ]), + h.button([testId(h, 'close'), h.OnClick(Message.ClickedClose())], ['close']), + errorLine(model, model.maybeCloseError, 'close error: ', h), + h.div([testId(h, 'comments')], [commentsView(model, id, h)]), + h.input([ + testId(h, 'comment-input'), + h.Value(model.commentInput), + h.OnInput(value => Message.UpdatedCommentInput({ value })), + ]), + h.button([testId(h, 'post'), h.OnClick(Message.ClickedPost())], ['post']), + errorLine(model, model.maybePostError, 'post error: ', h), +] + +export const view = Submodel.defineView((model, h) => + h.div([], [ + h.section([], [ + h.label([], [ + 'user ', + h.input([testId(h, 'user'), h.Value(model.user), h.OnInput(value => Message.UpdatedUser({ value }))]), + ]), + h.div([], [ + h.button([testId(h, 'filter-open'), h.OnClick(Message.ClickedFilter({ filter: 'open' }))], ['open']), + h.button([testId(h, 'filter-closed'), h.OnClick(Message.ClickedFilter({ filter: 'closed' }))], ['closed']), + ]), + h.div([testId(h, 'list')], [listView(model, h)]), + ]), + h.section( + [testId(h, 'detail')], + Option.match(model.maybeSelected, { + onNone: () => [h.p([], ['select an issue'])], + onSome: id => detailView(model, id, h), + }), + ), + ]), +) diff --git a/eval/reference/count/foldkit/main.ts b/eval/reference/count/foldkit/main.ts new file mode 100644 index 0000000..464451b --- /dev/null +++ b/eval/reference/count/foldkit/main.ts @@ -0,0 +1,146 @@ +import { Option, Schema } from 'effect' +import { Command, type Runtime, type Update } from 'foldkit' +import { Document, Html, HtmlBuilder } from 'foldkit/html' +import { defineMessageUnion } from 'foldkit/message' +import { defineTaggedUnion } from 'foldkit/schema' + +import * as Help from './help' +import * as Issues from './issues' +import * as Settings from './settings' + +// MODEL + +const Tab = Schema.Literals(['issues', 'help', 'settings']) +type Tab = typeof Tab.Type + +// Each opening of a page gets a new `pageId`. Answers to a page that was left carry an old one and are dropped. +export const Page = defineTaggedUnion({ + Issues: { model: Issues.Model }, + Help: { model: Help.Model }, + Settings: { model: Settings.Model }, +}) + +// The tab's open-issue count: the last one the issues page showed, kept while other pages are open. +export const Model = Schema.Struct({ pageId: Schema.Number, page: Page, maybeOpenCount: Schema.Option(Schema.Number) }) +export type Model = typeof Model.Type + +// MESSAGE + +export const Message = defineMessageUnion({ + ClickedTab: { tab: Tab }, + GotIssuesMessage: { pageId: Schema.Number, message: Issues.Message }, + GotHelpMessage: { pageId: Schema.Number, message: Help.Message }, + GotSettingsMessage: { pageId: Schema.Number, message: Settings.Message }, +}) +export type Message = typeof Message.Type + +// INIT + +const openIssues = ( + maybeOpenCount: Option.Option, + pageId: number, + result: Update.Return, +): Update.Return => ({ + model: { + pageId, + page: Page.Issues({ model: result.model }), + maybeOpenCount: Option.orElse(Issues.openCount(result.model), () => maybeOpenCount), + }, + commands: Command.mapMessages(result.commands, message => Message.GotIssuesMessage({ pageId, message })), +}) + +const openHelp = ( + maybeOpenCount: Option.Option, + pageId: number, + result: Update.Return, +): Update.Return => ({ + model: { pageId, page: Page.Help({ model: result.model }), maybeOpenCount }, + commands: Command.mapMessages(result.commands, message => Message.GotHelpMessage({ pageId, message })), +}) + +const openSettings = ( + maybeOpenCount: Option.Option, + pageId: number, + result: Update.Return, +): Update.Return => ({ + model: { pageId, page: Page.Settings({ model: result.model }), maybeOpenCount }, + commands: Command.mapMessages(result.commands, message => Message.GotSettingsMessage({ pageId, message })), +}) + +export const init: Runtime.ApplicationInit = () => openIssues(Option.none(), 1, Issues.init()) + +// UPDATE + +const tabOf = (page: typeof Page.Type): Tab => + Page.match(page, { Issues: () => 'issues', Help: () => 'help', Settings: () => 'settings' }) + +export const update = (model: Model, message: Message) => + Message.match>(message, { + ClickedTab: ({ tab }) => { + if (tab === tabOf(model.page)) { + return { model } + } + const pageId = model.pageId + 1 + return tab === 'issues' + ? openIssues(model.maybeOpenCount, pageId, Issues.init()) + : tab === 'help' + ? openHelp(model.maybeOpenCount, pageId, Help.init()) + : openSettings(model.maybeOpenCount, pageId, Settings.init()) + }, + + GotIssuesMessage: ({ pageId, message }) => + model.page._tag === 'Issues' && pageId === model.pageId + ? openIssues(model.maybeOpenCount, pageId, Issues.update(model.page.model, message)) + : { model }, + + GotHelpMessage: ({ pageId, message }) => + model.page._tag === 'Help' && pageId === model.pageId + ? openHelp(model.maybeOpenCount, pageId, Help.update(model.page.model, message)) + : { model }, + + GotSettingsMessage: ({ pageId, message }) => + model.page._tag === 'Settings' && pageId === model.pageId + ? openSettings(model.maybeOpenCount, pageId, Settings.update(model.page.model, message)) + : { model }, + }) + +// VIEW + +const pageView = (model: Model, h: HtmlBuilder): Html => + Page.match(model.page, { + Issues: page => + h.submodel({ + slotId: `issues-${model.pageId}`, + model: page.model, + view: Issues.view, + toParentMessage: message => Message.GotIssuesMessage({ pageId: model.pageId, message }), + }), + Help: page => + h.submodel({ + slotId: `help-${model.pageId}`, + model: page.model, + view: Help.view, + toParentMessage: message => Message.GotHelpMessage({ pageId: model.pageId, message }), + }), + Settings: page => + h.submodel({ + slotId: `settings-${model.pageId}`, + model: page.model, + view: Settings.view, + toParentMessage: message => Message.GotSettingsMessage({ pageId: model.pageId, message }), + }), + }) + +export const view = (model: Model, h: HtmlBuilder): Document => ({ + title: 'Issue desk', + body: h.div([], [ + h.nav([], [ + h.button([h.DataAttribute('testid', 'tab-issues'), h.OnClick(Message.ClickedTab({ tab: 'issues' }))], [ + Option.match(model.maybeOpenCount, { onNone: () => 'Issues', onSome: count => `Issues (${count})` }), + ]), + h.button([h.DataAttribute('testid', 'tab-help'), h.OnClick(Message.ClickedTab({ tab: 'help' }))], ['Help']), + h.button([h.DataAttribute('testid', 'tab-settings'), h.OnClick(Message.ClickedTab({ tab: 'settings' }))], ['Settings']), + ]), + pageView(model, h), + ]), +}) diff --git a/eval/reference/count/foldkit/request.ts b/eval/reference/count/foldkit/request.ts new file mode 100644 index 0000000..d744b31 --- /dev/null +++ b/eval/reference/count/foldkit/request.ts @@ -0,0 +1,7 @@ +import { Effect } from 'effect' + +export const errorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error) + +export const request = (call: () => Promise): Effect.Effect => + Effect.tryPromise({ try: call, catch: errorMessage }) diff --git a/eval/reference/count/foldkit/settings.ts b/eval/reference/count/foldkit/settings.ts new file mode 100644 index 0000000..01f5807 --- /dev/null +++ b/eval/reference/count/foldkit/settings.ts @@ -0,0 +1,201 @@ +import { Array, Effect, Option, Schema, pipe } from 'effect' +import { Command, Submodel, type Update } from 'foldkit' +import { Html, HtmlBuilder } from 'foldkit/html' +import { defineMessageUnion } from 'foldkit/message' +import { defineTaggedUnion } from 'foldkit/schema' +import { modifyFields } from 'foldkit/struct' + +import * as Api from './api' +import { request } from './request' + +// MODEL + +const Theme = Schema.Literals(['light', 'dark']) +const Density = Schema.Literals(['comfortable', 'compact']) +const Settings = Schema.Struct({ name: Schema.String, theme: Theme, density: Density }) +type Settings = typeof Settings.Type + +// The draft is seeded once from the loaded settings; after that it is the user's. +export const LoadState = defineTaggedUnion({ + Loading: {}, + Failed: { error: Schema.String }, + Loaded: { saved: Settings, draft: Settings }, +}) + +export const Model = Schema.Struct({ + loadSeq: Schema.Number, + load: LoadState, + seq: Schema.Number, + saving: Schema.Array(Schema.Struct({ seq: Schema.Number, value: Settings })), + confirmed: Schema.Number, + maybeSaveError: Schema.Option(Schema.String), +}) +export type Model = typeof Model.Type + +// MESSAGE + +export const Message = defineMessageUnion({ + SucceededLoad: { seq: Schema.Number, settings: Settings }, + FailedLoad: { seq: Schema.Number, error: Schema.String }, + ClickedRetryLoad: {}, + UpdatedName: { value: Schema.String }, + ClickedTheme: { theme: Theme }, + ClickedDensityToggle: {}, + ClickedSave: {}, + SucceededSave: { seq: Schema.Number, settings: Settings }, + FailedSave: { seq: Schema.Number, error: Schema.String }, +}) +export type Message = typeof Message.Type + +// COMMAND + +const Load = Command.define('Load', { + args: { seq: Schema.Number }, + messages: [Message.SucceededLoad, Message.FailedLoad], + execute: ({ seq }) => + request(() => Api.loadSettings()).pipe( + Effect.map(settings => Message.SucceededLoad({ seq, settings })), + Effect.catch(error => Effect.succeed(Message.FailedLoad({ seq, error }))), + ), +}) + +const Save = Command.define('Save', { + args: { seq: Schema.Number, settings: Settings }, + messages: [Message.SucceededSave, Message.FailedSave], + execute: ({ seq, settings }) => + request(() => Api.saveSettings(settings)).pipe( + Effect.map(saved => Message.SucceededSave({ seq, settings: saved })), + Effect.catch(error => Effect.succeed(Message.FailedSave({ seq, error }))), + ), +}) + +// INIT + +export const init = (): Update.Return => ({ + model: { loadSeq: 1, load: LoadState.Loading(), seq: 1, saving: [], confirmed: 0, maybeSaveError: Option.none() }, + commands: [Load({ seq: 1 })], +}) + +// UPDATE + +const nameError = (name: string): string => + name.length === 0 + ? 'Display name is required' + : name.length > 20 + ? `Display name must be at most 20 characters, not ${name.length}` + : '' + +const editDraft = (model: Model, f: (draft: Settings) => Settings): Model => + modifyFields(model, { + load: load => (load._tag === 'Loaded' ? LoadState.Loaded({ saved: load.saved, draft: f(load.draft) }) : load), + }) + +export const update = (model: Model, message: Message) => + Message.match>(message, { + SucceededLoad: ({ seq, settings }) => + seq === model.loadSeq + ? { model: modifyFields(model, { load: () => LoadState.Loaded({ saved: settings, draft: settings }) }) } + : { model }, + + FailedLoad: ({ seq, error }) => + seq === model.loadSeq ? { model: modifyFields(model, { load: () => LoadState.Failed({ error }) }) } : { model }, + + ClickedRetryLoad: () => { + const loadSeq = model.loadSeq + 1 + return { model: modifyFields(model, { loadSeq: () => loadSeq }), commands: [Load({ seq: loadSeq })] } + }, + + UpdatedName: ({ value }) => ({ model: editDraft(model, draft => ({ ...draft, name: value })) }), + + ClickedTheme: ({ theme }) => ({ model: editDraft(model, draft => ({ ...draft, theme })) }), + + ClickedDensityToggle: () => ({ + model: editDraft(model, draft => ({ ...draft, density: draft.density === 'compact' ? 'comfortable' : 'compact' })), + }), + + ClickedSave: () => { + if (model.load._tag !== 'Loaded' || nameError(model.load.draft.name) !== '') { + return { model } + } + const draft = model.load.draft + return { + model: modifyFields(model, { + seq: seq => seq + 1, + maybeSaveError: () => Option.none(), + saving: Array.append({ seq: model.seq, value: draft }), + }), + commands: [Save({ seq: model.seq, settings: draft })], + } + }, + + SucceededSave: ({ seq, settings }) => { + const isNewest = seq > model.confirmed + return { + model: modifyFields(model, { + saving: Array.filter(saving => saving.seq !== seq), + confirmed: confirmed => (isNewest ? seq : confirmed), + load: load => + isNewest && load._tag === 'Loaded' ? LoadState.Loaded({ saved: settings, draft: load.draft }) : load, + }), + } + }, + + FailedSave: ({ seq, error }) => ({ + model: modifyFields(model, { + saving: Array.filter(saving => saving.seq !== seq), + maybeSaveError: () => Option.some(error), + }), + }), + }) + +// VIEW + +const testId = (h: HtmlBuilder, id: string) => h.DataAttribute('testid', id) + +const loadedView = (model: Model, saved: Settings, draft: Settings, h: HtmlBuilder): Html => { + const maybeGuess = pipe( + model.saving, + Array.filter(saving => saving.seq > model.confirmed), + Array.last, + ) + const shown = Option.match(maybeGuess, { onNone: () => saved, onSome: guess => guess.value }) + const error = nameError(draft.name) + return h.main([], [ + h.p( + [testId(h, 'saved')], + [`Saved: ${shown.name} · ${shown.theme} · ${shown.density}${Option.isSome(maybeGuess) ? ' (saving…)' : ''}`], + ), + Option.match(model.maybeSaveError, { + onNone: () => h.empty, + onSome: saveError => h.p([testId(h, 'save-error')], [`Save failed: ${saveError}`]), + }), + h.div([], [ + h.label([], [ + 'Display name ', + h.input([testId(h, 'name'), h.Value(draft.name), h.OnInput(value => Message.UpdatedName({ value }))]), + ]), + h.small([testId(h, 'name-count')], [`${draft.name.length}/20`]), + error === '' ? h.empty : h.p([testId(h, 'name-error')], [error]), + h.div([], [ + h.span([], [`Theme: ${draft.theme}`]), + h.button([testId(h, 'theme-light'), h.OnClick(Message.ClickedTheme({ theme: 'light' }))], ['light']), + h.button([testId(h, 'theme-dark'), h.OnClick(Message.ClickedTheme({ theme: 'dark' }))], ['dark']), + h.button([testId(h, 'density-toggle'), h.OnClick(Message.ClickedDensityToggle())], ['Toggle density']), + h.p([testId(h, 'preview')], [`Preview: ${draft.theme} / ${draft.density}`]), + ]), + h.button([testId(h, 'save'), h.OnClick(Message.ClickedSave())], ['Save']), + ]), + ]) +} + +export const view = Submodel.defineView((model, h) => + LoadState.match(model.load, { + Loading: () => h.p([testId(h, 'load')], ['Loading settings…']), + Failed: ({ error }) => + h.section([], [ + h.p([testId(h, 'load')], [`Could not load settings: ${error}`]), + h.button([testId(h, 'retry-load'), h.OnClick(Message.ClickedRetryLoad())], ['Retry']), + ]), + Loaded: ({ saved, draft }) => loadedView(model, saved, draft, h), + }), +) diff --git a/eval/reference/count/react/App.tsx b/eval/reference/count/react/App.tsx new file mode 100644 index 0000000..563b80d --- /dev/null +++ b/eval/reference/count/react/App.tsx @@ -0,0 +1,43 @@ +import { useState, type ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import IssuesPage from "./IssuesPage"; +import HelpPage from "./HelpPage"; +import SettingsPage from "./SettingsPage"; + +type Page = "issues" | "help" | "settings"; + +// Each page gets its own query cache, made when the page opens, so leaving a page discards what it loaded. +function PageClient({ children }: { children: ReactNode }) { + const [client] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, refetchOnWindowFocus: false, refetchOnReconnect: false }, + }, + }), + ); + return {children}; +} + +export default function App() { + const [page, setPage] = useState("issues"); + const [openCount, setOpenCount] = useState(null); + return ( +
    + + + {page === "issues" ? : page === "help" ? : } + +
    + ); +} diff --git a/eval/reference/count/react/CommentList.tsx b/eval/reference/count/react/CommentList.tsx new file mode 100644 index 0000000..3218198 --- /dev/null +++ b/eval/reference/count/react/CommentList.tsx @@ -0,0 +1,30 @@ +import type { Comment } from "./api"; +import { errorMessage } from "./errors"; + +export type Sending = { seq: number; issueId: number; author: string; text: string }; + +type Props = { + comments: Comment[] | undefined; + isPending: boolean; + error: unknown; + sending: Sending[]; +}; + +export default function CommentList({ comments, isPending, error, sending }: Props) { + if (isPending) return

    loading comments

    ; + if (error || !comments) return

    comments error: {errorMessage(error)}

    ; + return ( +
      + {comments.map((c, i) => ( +
    • + {c.author}: {c.text} +
    • + ))} + {sending.map(p => ( +
    • + {p.author}: {p.text} (sending…) +
    • + ))} +
    + ); +} diff --git a/eval/reference/count/react/HelpPage.tsx b/eval/reference/count/react/HelpPage.tsx new file mode 100644 index 0000000..020d0ed --- /dev/null +++ b/eval/reference/count/react/HelpPage.tsx @@ -0,0 +1,84 @@ +import { useState } from "react"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { fetchItem, search, type Hit } from "./api"; + +export default function HelpPage() { + const [query, setQuery] = useState(""); + const [selected, setSelected] = useState(null); + return ( +
    + setQuery(e.target.value)} />{" "} + {query !== "" && }{" "} + +
    + ); +} + +function Results({ query, selected, onSelect }: { query: string; selected: number | null; onSelect: (id: number) => void }) { + const tooShort = query.length < 2; + const { data, error, isFetching } = useQuery({ + queryKey: ["search", query], + queryFn: () => search(query), + enabled: !tooShort, + placeholderData: keepPreviousData, + }); + if (tooShort) + return ( +
    +

    too short: {query}

    +
    + ); + return ( +
    + {isFetching &&

    searching…

    } + {error && !isFetching ? ( +

    search failed: {error.message}

    + ) : ( + data && ( +
    +

    + {data.length} results for {query} +

    +
      + {data.map((hit: Hit) => ( +
    • onSelect(hit.id)}> + {hit.id === selected ? "> " : ""} + {hit.name} +
    • + ))} +
    +
    + ) + )} +
    + ); +} + +function HelpItem({ id }: { id: number | null }) { + const { data, error, isPending, refetch } = useQuery({ + queryKey: ["item", id], + queryFn: () => fetchItem(id!), + enabled: id !== null, + }); + if (id === null) return

    pick a result

    ; + return ( +
    + {isPending ? ( +

    loading…

    + ) : error ? ( + <> +

    failed: {error.message}

    {" "} + + + ) : data === null ? ( +

    item {id} not found

    + ) : ( +
    +

    {data.name}

    {data.blurb}

    +
    + )} +
    + ); +} diff --git a/eval/reference/count/react/IssueDetail.tsx b/eval/reference/count/react/IssueDetail.tsx new file mode 100644 index 0000000..71d00fe --- /dev/null +++ b/eval/reference/count/react/IssueDetail.tsx @@ -0,0 +1,74 @@ +import type { Comment, IssueDetail as Detail } from "./api"; +import { errorMessage } from "./errors"; +import CommentList, { type Sending } from "./CommentList"; + +type Props = { + id: number; + user: string; + detail: Detail | undefined; + loading: boolean; + error: unknown; + onRetry: () => void; + onAssign: () => void; + assignError: string | null; + titleInput: string; + onTitleInput: (title: string) => void; + onRename: () => void; + renameError: string | null; + onClose: () => void; + closeError: string | null; + comments: Comment[] | undefined; + commentsPending: boolean; + commentsError: unknown; + sending: Sending[]; + commentInput: string; + onCommentInput: (text: string) => void; + onPost: () => void; + postError: string | null; +}; + +export default function IssueDetail(p: Props) { + let body; + if (p.loading) body =

    loading #{p.id}

    ; + else if (p.error || !p.detail) body =

    detail error: {errorMessage(p.error)}

    ; + else + body = ( + <> +

    + #{p.detail.id} {p.detail.title} +

    +

    {p.detail.body}

    + + ); + return ( + <> + {body} + + + {p.assignError &&

    {p.assignError}

    } +
    + p.onTitleInput(e.target.value)} /> + + {p.renameError &&

    rename error: {p.renameError}

    } +
    + + {p.closeError &&

    close error: {p.closeError}

    } +
    + +
    + p.onCommentInput(e.target.value)} /> + + {p.postError &&

    post error: {p.postError}

    } + + ); +} diff --git a/eval/reference/count/react/IssueList.tsx b/eval/reference/count/react/IssueList.tsx new file mode 100644 index 0000000..576ad8b --- /dev/null +++ b/eval/reference/count/react/IssueList.tsx @@ -0,0 +1,27 @@ +import type { Issue } from "./api"; +import { errorMessage } from "./errors"; + +type Props = { + filter: "open" | "closed"; + issues: Issue[] | undefined; + isPending: boolean; + error: unknown; + selected: number | null; + onSelect: (id: number) => void; +}; + +export default function IssueList({ filter, issues, isPending, error, selected, onSelect }: Props) { + if (isPending) return

    loading {filter} issues

    ; + if (error || !issues) return

    list error: {errorMessage(error)}

    ; + return ( +
      + {issues.map(issue => ( +
    • + +
    • + ))} +
    + ); +} diff --git a/eval/reference/count/react/IssuesPage.tsx b/eval/reference/count/react/IssuesPage.tsx new file mode 100644 index 0000000..9648a83 --- /dev/null +++ b/eval/reference/count/react/IssuesPage.tsx @@ -0,0 +1,240 @@ +import { useEffect, useRef, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { addComment, assign, closeIssue, fetchComments, fetchIssue, fetchIssues, renameIssue } from "./api"; +import type { Issue, IssueDetail as Detail } from "./api"; +import { errorMessage, type IssueError } from "./errors"; +import IssueList from "./IssueList"; +import IssueDetail from "./IssueDetail"; +import type { Sending } from "./CommentList"; + +type Filter = "open" | "closed"; +// a write still in the air: `seq` orders writes to the same issue, the latest one is what the issue shows +type Guess = { seq: number; value: T }; +type Guesses = Record[]>; + +const latest = (guesses: Guesses, id: number): T | undefined => guesses[id]?.at(-1)?.value; +const addGuess = (prev: Guesses, id: number, g: Guess): Guesses => ({ ...prev, [id]: [...(prev[id] ?? []), g] }); +const dropGuess = (prev: Guesses, id: number, seq: number): Guesses => { + const rest = (prev[id] ?? []).filter(g => g.seq !== seq); + const next = { ...prev }; + if (rest.length) next[id] = rest; + else delete next[id]; + return next; +}; + +export default function IssuesPage({ onOpenCount }: { onOpenCount: (n: number) => void }) { + const queryClient = useQueryClient(); + const [user, setUser] = useState("guest"); + const [filter, setFilter] = useState("open"); + const [selected, setSelected] = useState(null); + const seqRef = useRef(0); + const nextSeq = () => ++seqRef.current; + + // assignments in the air, and the newest one confirmed per issue (an older answer must not overwrite it) + const [assigning, setAssigning] = useState>({}); + const [assignError, setAssignError] = useState(null); + const lastAssignRef = useRef(0); + const confirmedAssignRef = useRef>({}); + + const [renaming, setRenaming] = useState>({}); + const [renameError, setRenameError] = useState(null); + const [titleInput, setTitleInput] = useState(""); + const lastRenameRef = useRef(0); + const confirmedRenameRef = useRef>({}); + + // closes in the air, by issue id, with how many are out + const [closing, setClosing] = useState>({}); + const [closeError, setCloseError] = useState(null); + const lastCloseRef = useRef(0); + + const [sending, setSending] = useState([]); + const [postError, setPostError] = useState(null); + const [commentInput, setCommentInput] = useState(""); + const lastPostRef = useRef(0); + + const list = useQuery({ queryKey: ["issues", filter], queryFn: () => fetchIssues(filter) }); + + const detail = useQuery({ + queryKey: ["issue", selected], + queryFn: async () => { + const id = selected as number; + const d = await fetchIssue(id); + if (d === null) throw new Error(`issue #${id} not found`); + return d; + }, + enabled: selected !== null, + }); + + const comments = useQuery({ + queryKey: ["comments", selected], + queryFn: () => fetchComments(selected as number), + enabled: selected !== null, + }); + + const shownIssues = list.data + ?.filter(issue => !(filter === "open" && closing[issue.id])) + .map(issue => ({ + ...issue, + title: latest(renaming, issue.id) ?? issue.title, + assignee: latest(assigning, issue.id) ?? issue.assignee, + })); + // the open list as shown (closes in flight already out of it) is the tab's count; other lists, loads and failures + // leave the count as it was + const openCount = filter === "open" && !list.isFetching && shownIssues ? shownIssues.length : null; + useEffect(() => { + if (openCount !== null) onOpenCount(openCount); + }, [openCount, onOpenCount]); + + const shownDetail = detail.data && { ...detail.data, title: latest(renaming, detail.data.id) ?? detail.data.title }; + + const select = (id: number) => { + setSelected(id); + setTitleInput(""); + setAssignError(null); + setRenameError(null); + setCloseError(null); + setPostError(null); + }; + + const doAssign = async (id: number, who: string) => { + const seq = nextSeq(); + lastAssignRef.current = seq; + setAssignError(null); + setAssigning(prev => addGuess(prev, id, { seq, value: who })); + try { + const res = await assign(id, who); + if ((confirmedAssignRef.current[id] ?? 0) < seq) { + confirmedAssignRef.current[id] = seq; + queryClient.setQueriesData({ queryKey: ["issues"] }, old => + old?.map(i => (i.id === id ? { ...i, assignee: res.assignee } : i)), + ); + } + } catch (e) { + // only report the failure if no later assignment has started since + if (lastAssignRef.current === seq) setAssignError({ issueId: id, message: errorMessage(e) }); + } finally { + setAssigning(prev => dropGuess(prev, id, seq)); + } + }; + + const doRename = async (id: number, title: string) => { + const seq = nextSeq(); + lastRenameRef.current = seq; + setRenameError(null); + setRenaming(prev => addGuess(prev, id, { seq, value: title })); + try { + const res = await renameIssue(id, title); + if ((confirmedRenameRef.current[id] ?? 0) < seq) { + confirmedRenameRef.current[id] = seq; + queryClient.setQueriesData({ queryKey: ["issues"] }, old => + old?.map(i => (i.id === id ? { ...i, title: res.title } : i)), + ); + queryClient.setQueryData(["issue", id], old => (old ? { ...old, title: res.title } : old)); + } + } catch (e) { + if (lastRenameRef.current === seq) setRenameError({ issueId: id, message: errorMessage(e) }); + } finally { + setRenaming(prev => dropGuess(prev, id, seq)); + } + }; + + const doClose = async (id: number) => { + const seq = nextSeq(); + lastCloseRef.current = seq; + setCloseError(null); + setClosing(prev => ({ ...prev, [id]: (prev[id] ?? 0) + 1 })); + try { + await closeIssue(id); + queryClient.setQueryData(["issues", "open"], old => old?.filter(i => i.id !== id)); + } catch (e) { + if (lastCloseRef.current === seq) setCloseError({ issueId: id, message: errorMessage(e) }); + } finally { + setClosing(prev => { + const next = { ...prev }; + if ((next[id] ?? 0) > 1) next[id] -= 1; + else delete next[id]; + return next; + }); + } + }; + + const doPost = async (id: number, author: string, text: string) => { + const seq = nextSeq(); + lastPostRef.current = seq; + setPostError(null); + setCommentInput(""); + setSending(prev => [...prev, { seq, issueId: id, author, text }]); + try { + const res = await addComment(id, author, text); + queryClient.setQueryData<{ author: string; text: string }[]>(["comments", id], old => (old ? [...old, res] : old)); + } catch (e) { + if (lastPostRef.current === seq) setPostError({ issueId: id, message: errorMessage(e) }); + } finally { + setSending(prev => prev.filter(p => p.seq !== seq)); + } + }; + + const errorFor = (e: IssueError | null) => (e && e.issueId === selected ? e.message : null); + + return ( +
    +
    + +
    + + +
    +
    + +
    +
    +
    + {selected === null ? ( +

    select an issue

    + ) : ( + void detail.refetch()} + onAssign={() => void doAssign(selected, user)} + assignError={errorFor(assignError)} + titleInput={titleInput} + onTitleInput={setTitleInput} + onRename={() => { + if (titleInput !== "") void doRename(selected, titleInput); + }} + renameError={errorFor(renameError)} + onClose={() => void doClose(selected)} + closeError={errorFor(closeError)} + comments={comments.data} + commentsPending={comments.isPending} + commentsError={comments.error} + sending={sending.filter(p => p.issueId === selected)} + commentInput={commentInput} + onCommentInput={setCommentInput} + onPost={() => { + if (commentInput !== "") void doPost(selected, user, commentInput); + }} + postError={errorFor(postError)} + /> + )} +
    +
    + ); +} diff --git a/eval/reference/count/react/SettingsPage.tsx b/eval/reference/count/react/SettingsPage.tsx new file mode 100644 index 0000000..d4ad74e --- /dev/null +++ b/eval/reference/count/react/SettingsPage.tsx @@ -0,0 +1,76 @@ +import { useRef, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { loadSettings, saveSettings, type Density, type Settings, type Theme } from "./api"; + +export default function SettingsPage() { + const queryClient = useQueryClient(); + const settings = useQuery({ queryKey: ["settings"], queryFn: loadSettings }); + const [saveError, setSaveError] = useState(""); + // saves still in the air, oldest first; the newest one shows until it answers or a newer one is confirmed + const [saving, setSaving] = useState<{ seq: number; value: Settings }[]>([]); + const seqRef = useRef(0); + const confirmedRef = useRef(0); + + const save = async (next: Settings) => { + const seq = ++seqRef.current; + setSaveError(""); + setSaving(prev => [...prev, { seq, value: next }]); + try { + const saved = await saveSettings(next); + if (seq > confirmedRef.current) { + confirmedRef.current = seq; + queryClient.setQueryData(["settings"], saved); + } + } catch (e) { + setSaveError(e instanceof Error ? e.message : String(e)); + } finally { + setSaving(prev => prev.filter(p => p.seq !== seq)); + } + }; + + if (settings.isPending) return

    Loading settings…

    ; + if (settings.error) return ( +
    +

    Could not load settings: {settings.error.message}

    {" "} + +
    + ); + const guess = saving.filter(p => p.seq > confirmedRef.current).at(-1); + const s = guess?.value ?? settings.data; + return ( +
    +

    Saved: {s.name} · {s.theme} · {s.density}{guess ? " (saving…)" : ""}

    {" "} + {saveError &&

    Save failed: {saveError}

    } + void save(draft)} /> +
    + ); +} + +function nameError(name: string) { + if (name.length === 0) return "Display name is required"; + if (name.length > 20) return `Display name must be at most 20 characters, not ${name.length}`; + return ""; +} + +// Seeded once from the loaded settings; after that the draft is the user's. +function SettingsForm({ initial, onSave }: { initial: Settings; onSave: (draft: Settings) => void }) { + const [name, setName] = useState(initial.name); + const [theme, setTheme] = useState(initial.theme); + const [density, setDensity] = useState(initial.density); + const error = nameError(name); + return ( +
    + {" "} + {name.length}/20{" "} + {error &&

    {error}

    } +
    + Theme: {theme}{" "} + {" "} + {" "} + {" "} +

    Preview: {theme} / {density}

    +
    + +
    + ); +} diff --git a/eval/reference/count/react/errors.ts b/eval/reference/count/react/errors.ts new file mode 100644 index 0000000..c380616 --- /dev/null +++ b/eval/reference/count/react/errors.ts @@ -0,0 +1,4 @@ +export const errorMessage = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +/** an error that belongs to one issue: shown only while that issue is selected */ +export type IssueError = { issueId: number; message: string }; diff --git a/eval/reference/count/rx/comments.rx b/eval/reference/count/rx/comments.rx new file mode 100644 index 0000000..3e9b7b2 --- /dev/null +++ b/eval/reference/count/rx/comments.rx @@ -0,0 +1,55 @@ +// The selected issue's comments, and posting one. Posts in flight live in their own source, so changing the +// selection never waits for them, and each shows only under its own issue. + +import pure { fetchComments } from "./api" +import { addComment } from "./api" + +export component Comments(props: { id: number; user: string }) { + source comments = await fetchComments(props.id) + source sending: { id: number; author: string; text: string }[] = [] + shownSending = sending.filter(s => s.id == props.id) + source postError: string | null = null + source commentInput: string := "" + + // a post error belongs to the issue it was made on: selecting another clears it + effect (props.id) { postError := null } + + action postComment(id: number, author: string, text: string) { + optimistic postError := null + try { + optimistic sending := [...sending, { id: id, author: author, text: text }] + await addComment(id, author, text) + refresh comments + } catch (e) { + postError := e.message + } + } + +
    +
    + {loading on(props.id) fallback {

    loading comments

    } { + {errored fallback(e) {

    comments error: {e.message}

    } { +
      + {for c in comments { +
    • {c.author}: {c.text}
    • + }} + {for s in shownSending { +
    • {s.author}: {s.text} (sending…)
    • + }} +
    + }} + }} +
    + { commentInput := e.target.value }} /> + + {if postError != null { +

    post error: {postError}

    + }} +
    +} diff --git a/eval/reference/count/rx/help.rx b/eval/reference/count/rx/help.rx new file mode 100644 index 0000000..7217eef --- /dev/null +++ b/eval/reference/count/rx/help.rx @@ -0,0 +1,52 @@ +// The help page: search the help catalogue and open one result. + +import pure { search, fetchItem } from "./api" + +export error TooShort(q: string) => `too short: ${q}` +export error ItemNotFound(id: number) => `item ${id} not found` + +fn foundItem(it, id: number) => it == null ? throw ItemNotFound(id) : it + +export component HelpPage() { + source query: string := "" + source selected: number | null := null + results = query == "" ? [] : query.length < 2 ? throw TooShort(query) : await search(query) + item = selected == null ? null : foundItem(await fetchItem(selected), selected) + +
    + { query := e.target.value }} /> + {if query != "" { +
    + {errored fallback(e) {

    search failed: {e.message}

    } { + errored fallback(e: TooShort) {

    {e.message}

    } { + loading fallback {

    searching…

    } { +

    {pending? results ? "searching…" : ""}

    +

    {results.length} results for {latest query}

    +
      + {for r in results { +
    • { selected := r.id }}>{r.id == selected ? "> " : ""}{r.name}
    • + }} +
    + } + } + }} +
    + }} + {if selected == null { +

    pick a result

    + } else { +
    + {loading on(selected) fallback {

    loading…

    } { + {for id in [selected] { + {errored fallback(e) {

    failed: {e.message}

    } { + {errored fallback(e: ItemNotFound) {

    {e.message}

    } { +

    {item.name}

    +

    {item.blurb}

    + }} + }} + }} + }} +
    + }} +
    +} diff --git a/eval/reference/count/rx/issues.rx b/eval/reference/count/rx/issues.rx new file mode 100644 index 0000000..ae17232 --- /dev/null +++ b/eval/reference/count/rx/issues.rx @@ -0,0 +1,129 @@ +// The issues page: the filtered list, the selected issue's detail, and the writes on it (assign, rename, close). + +import pure { fetchIssues, fetchIssue } from "./api" +import { assign, renameIssue, closeIssue } from "./api" +import { Comments } from "./comments.rx" + +export error NotFound(id: number) => `issue #${id} not found` + +fn found(it, id: number) => it == null ? throw NotFound(id) : it + +export component IssuesPage(props: { onOpenCount: (n: number) => void }) { + source user: string := "guest" + source filter: string := "open" + source selected: number | null := null + source issues key id = await fetchIssues(filter) + source detail = selected == null ? null : found(await fetchIssue(selected), selected) + source titleInput: string := "" + source assignError: string = "" + source renameError: string = "" + source closeError: string = "" + + // the open list as shown (closes in flight already out of it) is the tab's count; other lists, loads and failures + // leave the count as it was + effect ((filter == "open" ? issues.length : -1) catch (e) => -1) { + if ($0 >= 0) { + props.onOpenCount($0) + } + } + + action assignTo(id: number, who: string) { + optimistic assignError := "" + try { + let i = issues.findIndex(t => t.id == id) + optimistic issues[i].assignee := who + await assign(id, who) + refresh issues + } catch (e) { + assignError := e.message + } + } + + action renameTo(id: number, title: string) { + optimistic renameError := "" + try { + let i = issues.findIndex(t => t.id == id) + if (i >= 0) { + optimistic issues[i].title := title + } + if (detail != null && detail.id == id) { + optimistic detail.title := title + } + await renameIssue(id, title) + refresh issues + refresh detail + } catch (e) { + renameError := e.message + } + } + + action closeOne(id: number) { + optimistic closeError := "" + try { + optimistic issues := issues.filter(t => t.id != id) + await closeIssue(id) + refresh issues + } catch (e) { + closeError := e.message + } + } + +
    +
    + +
    +
    + + +
    +
    + {loading on(filter) fallback {

    loading {latest filter} issues

    } { + {errored fallback(e) {

    list error: {e.message}

    } { +
      + {for it in issues { +
    • { + selected := it.id + titleInput := "" + onFlush(() => { + assignError := "" + renameError := "" + closeError := "" + }) + }}>#{it.id} {it.title} ({it.assignee ?? "unassigned"})
    • + }} +
    + }} + }} +
    +
    + {if selected != null { + {loading on(selected) fallback {

    loading #{latest selected}

    } { + {errored fallback(e) {

    detail error: {e.message}

    } { +

    #{detail.id} {detail.title}

    +

    {detail.body}

    + }} + + }} + + {if assignError != "" { +

    {assignError}

    + }} + { titleInput := e.target.value }} /> + + {if renameError != "" { +

    rename error: {renameError}

    + }} + + {if closeError != "" { +

    close error: {closeError}

    + }} + + } else { +

    select an issue

    + }} +
    +
    +} diff --git a/eval/reference/count/rx/main.rx b/eval/reference/count/rx/main.rx new file mode 100644 index 0000000..c7404b0 --- /dev/null +++ b/eval/reference/count/rx/main.rx @@ -0,0 +1,27 @@ +// The desk: three pages behind tabs. Only the open page is mounted, so leaving one discards its state. + +import { IssuesPage } from "./issues.rx" +import { HelpPage } from "./help.rx" +import { SettingsPage } from "./settings.rx" + +export default component App() { + source page: string := "issues" + source openCount: number | null := null + +
    + + {if page == "issues" { + { openCount := n }} /> + } else { + {if page == "help" { + + } else { + + }} + }} +
    +} diff --git a/eval/reference/count/rx/settings.rx b/eval/reference/count/rx/settings.rx new file mode 100644 index 0000000..0df8231 --- /dev/null +++ b/eval/reference/count/rx/settings.rx @@ -0,0 +1,61 @@ +// The settings page: the saved settings, a draft form seeded from them, and saving. + +import pure { loadSettings } from "./api" +import { saveSettings, Settings } from "./api" + +export error NameRequired() => "Display name is required" +export error NameTooLong(count: number) => `Display name must be at most 20 characters, not ${count}` + +fn validName(name: string): string => + name.length == 0 ? throw NameRequired() + : name.length > 20 ? throw NameTooLong(name.length) + : name + +export component SettingsPage() { + source settings: Settings = await loadSettings() + source saveError: string = "" + + action save(next: Settings) { + optimistic settings := next + optimistic saveError := "" + affects settings + try { + let saved = await saveSettings(next) + settings := saved + } catch (e) { + saveError := e.message + } + } + + errored fallback(e) { +

    Could not load settings: {e.message}

    + + } { + loading fallback {

    Loading settings…

    } { + source draftName: string := peek(settings.name) + source draftTheme: string := peek(settings.theme) + source draftDensity: string := peek(settings.density) + nameError: string = (validName(draftName) && "") catch (e: NameRequired | NameTooLong) => e.message +

    Saved: {settings.name} · {settings.theme} · {settings.density}{pending? settings ? " (saving…)" : ""}

    + {if saveError != "" { +

    Save failed: {saveError}

    + }} + {" "} + {draftName.length}/20 + {if nameError != "" { +

    {nameError}

    + }} +
    + {" "} + {" "} + +
    +

    Preview: {draftTheme} / {draftDensity}

    + + } + } +} diff --git a/eval/reference/count/solid/App.tsx b/eval/reference/count/solid/App.tsx new file mode 100644 index 0000000..3f434fa --- /dev/null +++ b/eval/reference/count/solid/App.tsx @@ -0,0 +1,26 @@ +import { createSignal, Match, Switch } from "solid-js"; +import IssuesPage from "./IssuesPage"; +import HelpPage from "./HelpPage"; +import SettingsPage from "./SettingsPage"; + +type Page = "issues" | "help" | "settings"; + +// Only the open page is mounted, so leaving one discards its state. +export default function App() { + const [page, setPage] = createSignal("issues"); + const [openCount, setOpenCount] = createSignal(null); + return ( +
    + + + + + + +
    + ); +} diff --git a/eval/reference/count/solid/Comments.tsx b/eval/reference/count/solid/Comments.tsx new file mode 100644 index 0000000..1518ac3 --- /dev/null +++ b/eval/reference/count/solid/Comments.tsx @@ -0,0 +1,65 @@ +import { action, createEffect, createMemo, createOptimisticStore, createSignal, Errored, For, Loading, onFlush, refresh, Show } from "solid-js"; +import { addComment, fetchComments } from "./api"; + +const message = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +type Sending = { id: number; author: string; text: string }; + +// The selected issue's comments, and posting one. Posts in flight live in their own store, so changing the selection +// never waits for them, and each shows only under its own issue. +export default function Comments(props: { id: number; user: string }) { + const comments = createMemo(() => fetchComments(props.id)); + const [sending, setSending] = createOptimisticStore([]); + const [postError, setPostError] = createSignal(""); + const [commentInput, setCommentInput] = createSignal(""); + + // a post error belongs to the issue it was made on: selecting another clears it + createEffect(() => props.id, () => { + setPostError(""); + }); + + const post = action(function* (id: number, author: string, text: string) { + try { + setSending(s => { s.push({ id, author, text }); }); + yield addComment(id, author, text); + refresh(comments); + } catch (e) { + setPostError(message(e)); + } + }); + + return ( +
    +
    + loading comments

    }> +

    comments error: {message(e())}

    }> +
      + {c =>
    • {c.author}: {c.text}
    • }
      + s.id === props.id)}> + {s =>
    • {s.author}: {s.text} (sending…)
    • } +
      +
    +
    +
    +
    + setCommentInput(e.currentTarget.value)} /> + + +

    post error: {postError()}

    +
    +
    + ); +} diff --git a/eval/reference/count/solid/HelpPage.tsx b/eval/reference/count/solid/HelpPage.tsx new file mode 100644 index 0000000..40226a6 --- /dev/null +++ b/eval/reference/count/solid/HelpPage.tsx @@ -0,0 +1,82 @@ +import { createMemo, createSignal, Errored, For, isPending, latest, Loading, refresh, Show } from "solid-js"; +import { fetchItem, search } from "./api"; + +const message = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +class TooShort extends Error {} +class ItemNotFound extends Error {} + +export default function HelpPage() { + const [query, setQuery] = createSignal(""); + const [selected, setSelected] = createSignal(null); + + // empty: nothing; one character: too short, nothing asked; otherwise the search + const results = createMemo(() => { + const q = query(); + if (q === "") return []; + if (q.length < 2) throw new TooShort(`too short: ${q}`); + return search(q); + }); + const item = createMemo(async () => { + const id = selected(); + if (id === null) return null; + const it = await fetchItem(id); + if (it === null) throw new ItemNotFound(`item ${id} not found`); + return it; + }); + + return ( +
    + {/* the input shows what was typed even while the search it started is in flight */} + setQuery(e.currentTarget.value)} /> + +
    + (e() instanceof TooShort ?

    {message(e())}

    :

    search failed: {message(e())}

    )}> + searching…

    }> +

    {isPending(() => results()) ? "searching…" : ""}

    +

    + {results().length} results for {latest(query)} +

    +
      + + {r => ( +
    • setSelected(r.id)}> + {r.id === selected() ? "> " : ""} + {r.name} +
    • + )} +
      +
    +
    +
    +
    +
    + pick a result

    }> +
    + loading…

    }> + {/* a fresh region per selection, so a failure shown for one item doesn't stay up while the next loads */} + + {_id => ( + + e() instanceof ItemNotFound ? ( +

    {message(e())}

    + ) : ( + <> +

    failed: {message(e())}

    + + + ) + } + > +

    {item()?.name}

    +

    {item()?.blurb}

    +
    + )} +
    +
    +
    +
    +
    + ); +} diff --git a/eval/reference/count/solid/IssuesPage.tsx b/eval/reference/count/solid/IssuesPage.tsx new file mode 100644 index 0000000..0c1b39b --- /dev/null +++ b/eval/reference/count/solid/IssuesPage.tsx @@ -0,0 +1,168 @@ +import { action, createEffect, createMemo, createOptimistic, createOptimisticStore, createSignal, Errored, For, latest, Loading, onFlush, refresh, Show } from "solid-js"; +import { assign, closeIssue, fetchIssue, fetchIssues, renameIssue, type Issue, type IssueDetail } from "./api"; +import Comments from "./Comments"; + +const message = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +export default function IssuesPage(props: { onOpenCount: (n: number) => void }) { + const [user, setUser] = createSignal("guest"); + const [filter, setFilter] = createSignal<"open" | "closed">("open"); + const [selected, setSelected] = createSignal(null); + const [titleInput, setTitleInput] = createSignal(""); + const [assignError, setAssignError] = createSignal(""); + const [renameError, setRenameError] = createSignal(""); + const [closeError, setCloseError] = createSignal(""); + + // the list for the filter; writes in an action are guesses until it settles + const [issues, setIssues] = createOptimisticStore(() => fetchIssues(filter()), [], { key: "id" }); + const [detail, setDetail] = createOptimistic(async () => { + const id = selected(); + if (id === null) return null; + const d = await fetchIssue(id); + if (d === null) throw new Error(`issue #${id} not found`); + return d; + }); + + // the open list as shown (closes in flight already out of it) is the tab's count; other lists, loads and failures + // leave the count as it was + const openCount = createMemo(() => { + try { + return filter() === "open" ? issues.length : -1; + } catch { + return -1; + } + }); + createEffect(openCount, n => { + if (n >= 0) props.onOpenCount(n); + }); + + const assignTo = action(function* (id: number, who: string) { + try { + setIssues(list => { + const row = list.find(t => t.id === id); + if (row) row.assignee = who; + }); + yield assign(id, who); + refresh(issues); + } catch (e) { + setAssignError(message(e)); + } + }); + + const renameTo = action(function* (id: number, title: string) { + try { + setIssues(list => { + const row = list.find(t => t.id === id); + if (row) row.title = title; + }); + setDetail(d => (d && d.id === id ? { ...d, title } : d)); + yield renameIssue(id, title); + refresh(issues); + refresh(detail); + } catch (e) { + setRenameError(message(e)); + } + }); + + const closeOne = action(function* (id: number) { + try { + setIssues(list => { + const i = list.findIndex(t => t.id === id); + if (i >= 0) list.splice(i, 1); + }); + yield closeIssue(id); + refresh(issues); + } catch (e) { + setCloseError(message(e)); + } + }); + + const select = (id: number) => { + setSelected(id); + setTitleInput(""); + onFlush(() => { + setAssignError(""); + setRenameError(""); + setCloseError(""); + }); + }; + + return ( +
    +
    + +
    +
    + + +
    +
    + loading {latest(filter)} issues

    }> +

    list error: {message(e())}

    }> +
      + + {it => ( +
    • select(it.id)}> + #{it.id} {it.title} ({it.assignee ?? "unassigned"}) +
    • + )} +
      +
    +
    +
    +
    +
    + select an issue

    }> + loading #{latest(selected)}

    }> +

    detail error: {message(e())}

    }> +

    #{detail()?.id} {detail()?.title}

    +

    {detail()?.body}

    +
    + +
    + + +

    {assignError()}

    +
    + setTitleInput(e.currentTarget.value)} /> + + +

    rename error: {renameError()}

    +
    + + +

    close error: {closeError()}

    +
    + +
    +
    +
    + ); +} diff --git a/eval/reference/count/solid/SettingsPage.tsx b/eval/reference/count/solid/SettingsPage.tsx new file mode 100644 index 0000000..5e949d3 --- /dev/null +++ b/eval/reference/count/solid/SettingsPage.tsx @@ -0,0 +1,92 @@ +import { action, affects, createOptimistic, createSignal, Errored, isPending, Loading, onFlush, refresh, Show, untrack } from "solid-js"; +import { loadSettings, saveSettings, type Density, type Settings, type Theme } from "./api"; + +const message = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +function nameError(name: string) { + if (name.length === 0) return "Display name is required"; + if (name.length > 20) return `Display name must be at most 20 characters, not ${name.length}`; + return ""; +} + +export default function SettingsPage() { + // what the server has; a save shows its settings over it as a guess until it settles + const [saved, setSaved] = createSignal(() => loadSettings()); + const [settings, setSettings] = createOptimistic(() => saved()); + const [saveError, setSaveError] = createSignal(""); + + const save = action(function* (next: Settings) { + setSettings(next); + affects(settings); + try { + setSaved(yield saveSettings(next)); + } catch (e) { + setSaveError(message(e)); + } + }); + + return ( + ( + <> +

    Could not load settings: {message(e())}

    + + + )} + > + Loading settings…

    }> +

    + Saved: {settings().name} · {settings().theme} · {settings().density} + {isPending(() => settings()) ? " (saving…)" : ""} +

    + +

    Save failed: {saveError()}

    +
    + { + onFlush(() => setSaveError("")); + save(s); + }} + /> +
    +
    + ); +} + +// Seeded once from the loaded settings; after that the draft is the user's. +function SettingsForm(props: { initial: Settings; onSave: (s: Settings) => void }) { + const [name, setName] = createSignal(untrack(() => props.initial.name)); + const [theme, setTheme] = createSignal(untrack(() => props.initial.theme)); + const [density, setDensity] = createSignal(untrack(() => props.initial.density)); + const error = () => nameError(name()); + return ( +
    + {" "} + {name().length}/20 + +

    {error()}

    +
    +
    + {" "} + {" "} + +
    +

    + Preview: {theme()} / {density()} +

    + +
    + ); +} diff --git a/eval/reference/desk/foldkit/help.ts b/eval/reference/desk/foldkit/help.ts new file mode 100644 index 0000000..b9f086d --- /dev/null +++ b/eval/reference/desk/foldkit/help.ts @@ -0,0 +1,213 @@ +import { Array, Effect, Option, Schema } from 'effect' +import { Command, Submodel, type Update } from 'foldkit' +import { Html, HtmlBuilder } from 'foldkit/html' +import { defineMessageUnion } from 'foldkit/message' +import { defineTaggedUnion } from 'foldkit/schema' +import { modifyFields } from 'foldkit/struct' + +import * as Api from './api' +import { request } from './request' + +// MODEL + +const Hit = Schema.Struct({ id: Schema.Number, name: Schema.String }) +const Item = Schema.Struct({ id: Schema.Number, name: Schema.String, blurb: Schema.String }) + +// The results on screen stay until a newer query's answer replaces them. +const Shown = Schema.Struct({ query: Schema.String, hits: Schema.Array(Hit) }) + +export const ItemState = defineTaggedUnion({ + Loading: {}, + Loaded: { maybeItem: Schema.Option(Item) }, + Failed: { error: Schema.String }, +}) + +export const Model = Schema.Struct({ + query: Schema.String, + searchSeq: Schema.Number, + isSearching: Schema.Boolean, + maybeShown: Schema.Option(Shown), + maybeSearchError: Schema.Option(Schema.String), + maybeSelected: Schema.Option(Schema.Number), + itemSeq: Schema.Number, + item: ItemState, +}) +export type Model = typeof Model.Type + +// MESSAGE + +export const Message = defineMessageUnion({ + UpdatedQuery: { value: Schema.String }, + SucceededSearch: { seq: Schema.Number, query: Schema.String, hits: Schema.Array(Hit) }, + FailedSearch: { seq: Schema.Number, error: Schema.String }, + ClickedHit: { id: Schema.Number }, + SucceededFetchItem: { seq: Schema.Number, item: Schema.NullOr(Item) }, + FailedFetchItem: { seq: Schema.Number, error: Schema.String }, + ClickedRetryItem: {}, +}) +export type Message = typeof Message.Type + +// COMMAND + +const Search = Command.define('Search', { + args: { seq: Schema.Number, query: Schema.String }, + messages: [Message.SucceededSearch, Message.FailedSearch], + execute: ({ seq, query }) => + request(() => Api.search(query)).pipe( + Effect.map(hits => Message.SucceededSearch({ seq, query, hits })), + Effect.catch(error => Effect.succeed(Message.FailedSearch({ seq, error }))), + ), +}) + +const FetchItem = Command.define('FetchItem', { + args: { seq: Schema.Number, id: Schema.Number }, + messages: [Message.SucceededFetchItem, Message.FailedFetchItem], + execute: ({ seq, id }) => + request(() => Api.fetchItem(id)).pipe( + Effect.map(item => Message.SucceededFetchItem({ seq, item })), + Effect.catch(error => Effect.succeed(Message.FailedFetchItem({ seq, error }))), + ), +}) + +// INIT + +export const init = (): Update.Return => ({ + model: { + query: '', + searchSeq: 0, + isSearching: false, + maybeShown: Option.none(), + maybeSearchError: Option.none(), + maybeSelected: Option.none(), + itemSeq: 0, + item: ItemState.Loading(), + }, +}) + +// UPDATE + +export const update = (model: Model, message: Message) => + Message.match>(message, { + UpdatedQuery: ({ value }) => { + const searchSeq = model.searchSeq + 1 + const isAsked = value.length >= 2 + const next = modifyFields(model, { + query: () => value, + searchSeq: () => searchSeq, + isSearching: () => isAsked, + maybeSearchError: () => Option.none(), + maybeShown: shown => (value === '' ? Option.none() : shown), + }) + return isAsked ? { model: next, commands: [Search({ seq: searchSeq, query: value })] } : { model: next } + }, + + SucceededSearch: ({ seq, query, hits }) => + seq === model.searchSeq + ? { + model: modifyFields(model, { + isSearching: () => false, + maybeShown: () => Option.some({ query, hits }), + }), + } + : { model }, + + FailedSearch: ({ seq, error }) => + seq === model.searchSeq + ? { model: modifyFields(model, { isSearching: () => false, maybeSearchError: () => Option.some(error) }) } + : { model }, + + ClickedHit: ({ id }) => { + if (Option.contains(model.maybeSelected, id)) { + return { model } + } + const itemSeq = model.itemSeq + 1 + return { + model: modifyFields(model, { + maybeSelected: () => Option.some(id), + itemSeq: () => itemSeq, + item: () => ItemState.Loading(), + }), + commands: [FetchItem({ seq: itemSeq, id })], + } + }, + + ClickedRetryItem: () => + Option.match(model.maybeSelected, { + onNone: () => ({ model }), + onSome: id => { + const itemSeq = model.itemSeq + 1 + return { model: modifyFields(model, { itemSeq: () => itemSeq }), commands: [FetchItem({ seq: itemSeq, id })] } + }, + }), + + SucceededFetchItem: ({ seq, item }) => + seq === model.itemSeq + ? { model: modifyFields(model, { item: () => ItemState.Loaded({ maybeItem: Option.fromNullishOr(item) }) }) } + : { model }, + + FailedFetchItem: ({ seq, error }) => + seq === model.itemSeq ? { model: modifyFields(model, { item: () => ItemState.Failed({ error }) }) } : { model }, + }) + +// VIEW + +const testId = (h: HtmlBuilder, id: string) => h.DataAttribute('testid', id) + +const resultsView = (model: Model, h: HtmlBuilder): Html => { + if (model.query.length < 2) { + return h.section([testId(h, 'results')], [h.p([], [`too short: ${model.query}`])]) + } + const shown = Option.match(model.maybeSearchError, { + onSome: error => (model.isSearching ? h.empty : h.p([], [`search failed: ${error}`])), + onNone: () => + Option.match(model.maybeShown, { + onNone: () => h.empty, + onSome: ({ query, hits }) => + h.div([], [ + h.h2([], [`${hits.length} results for ${query}`]), + h.ul( + [], + Array.map(hits, hit => + h.keyed('li')( + String(hit.id), + [testId(h, 'hit'), h.OnClick(Message.ClickedHit({ id: hit.id }))], + [`${Option.contains(model.maybeSelected, hit.id) ? '> ' : ''}${hit.name}`], + ), + ), + ), + ]), + }), + }) + return h.section([testId(h, 'results')], [model.isSearching ? h.p([], ['searching…']) : h.empty, shown]) +} + +const itemView = (model: Model, id: number, h: HtmlBuilder): Html => + h.section( + [testId(h, 'item')], + [ + ItemState.match(model.item, { + Loading: () => h.p([], ['loading…']), + Failed: ({ error }) => + h.div([], [ + h.p([], [`failed: ${error}`]), + h.button([testId(h, 'retry-item'), h.OnClick(Message.ClickedRetryItem())], ['retry']), + ]), + Loaded: ({ maybeItem }) => + Option.match(maybeItem, { + onNone: () => h.p([], [`item ${id} not found`]), + onSome: item => h.article([], [h.h3([], [item.name]), h.p([], [item.blurb])]), + }), + }), + ], + ) + +export const view = Submodel.defineView((model, h) => + h.main([], [ + h.input([testId(h, 'query'), h.Value(model.query), h.OnInput(value => Message.UpdatedQuery({ value }))]), + model.query === '' ? h.empty : resultsView(model, h), + Option.match(model.maybeSelected, { + onNone: () => h.p([testId(h, 'item')], ['pick a result']), + onSome: id => itemView(model, id, h), + }), + ]), +) diff --git a/eval/reference/desk/foldkit/issues.ts b/eval/reference/desk/foldkit/issues.ts new file mode 100644 index 0000000..7622e13 --- /dev/null +++ b/eval/reference/desk/foldkit/issues.ts @@ -0,0 +1,596 @@ +import { Array, Effect, Option, Schema, pipe } from 'effect' +import { Command, Submodel, type Update } from 'foldkit' +import { Html, HtmlBuilder } from 'foldkit/html' +import { defineMessageUnion } from 'foldkit/message' +import { defineTaggedUnion } from 'foldkit/schema' +import { modifyFields } from 'foldkit/struct' + +import * as Api from './api' +import { request } from './request' + +// MODEL + +const Filter = Schema.Literals(['open', 'closed']) +type Filter = typeof Filter.Type + +const Issue = Schema.Struct({ id: Schema.Number, title: Schema.String, assignee: Schema.NullOr(Schema.String) }) +type Issue = typeof Issue.Type +const Detail = Schema.Struct({ id: Schema.Number, title: Schema.String, body: Schema.String }) +const Comment = Schema.Struct({ author: Schema.String, text: Schema.String }) + +export const ListState = defineTaggedUnion({ + Loading: {}, + Loaded: { issues: Schema.Array(Issue) }, + Failed: { error: Schema.String }, +}) +export const DetailState = defineTaggedUnion({ + Loading: {}, + Loaded: { detail: Detail }, + Failed: { error: Schema.String }, +}) +export const CommentsState = defineTaggedUnion({ + Loading: {}, + Loaded: { comments: Schema.Array(Comment) }, + Failed: { error: Schema.String }, +}) + +// A write still in the air. `seq` orders writes: the newest guess for an issue is what it shows. +const Guess = Schema.Struct({ seq: Schema.Number, id: Schema.Number, value: Schema.String }) +type Guess = typeof Guess.Type +const Sending = Schema.Struct({ seq: Schema.Number, id: Schema.Number, author: Schema.String, text: Schema.String }) +// An error that belongs to one issue: shown only while that issue is selected. +const IssueError = Schema.Struct({ id: Schema.Number, message: Schema.String }) +type IssueError = typeof IssueError.Type +// The newest write of a kind that the server confirmed, per issue. +const Confirmed = Schema.Struct({ id: Schema.Number, seq: Schema.Number }) +type Confirmed = typeof Confirmed.Type + +export const Model = Schema.Struct({ + user: Schema.String, + filter: Filter, + listSeq: Schema.Number, + list: ListState, + maybeSelected: Schema.Option(Schema.Number), + detailSeq: Schema.Number, + detail: DetailState, + comments: CommentsState, + seq: Schema.Number, + assigning: Schema.Array(Guess), + confirmedAssigns: Schema.Array(Confirmed), + lastAssign: Schema.Number, + maybeAssignError: Schema.Option(IssueError), + titleInput: Schema.String, + renaming: Schema.Array(Guess), + confirmedRenames: Schema.Array(Confirmed), + lastRename: Schema.Number, + maybeRenameError: Schema.Option(IssueError), + closing: Schema.Array(Schema.Struct({ seq: Schema.Number, id: Schema.Number })), + lastClose: Schema.Number, + maybeCloseError: Schema.Option(IssueError), + commentInput: Schema.String, + sending: Schema.Array(Sending), + lastPost: Schema.Number, + maybePostError: Schema.Option(IssueError), +}) +export type Model = typeof Model.Type + +// MESSAGE + +export const Message = defineMessageUnion({ + UpdatedUser: { value: Schema.String }, + ClickedFilter: { filter: Filter }, + SucceededFetchIssues: { seq: Schema.Number, issues: Schema.Array(Issue) }, + FailedFetchIssues: { seq: Schema.Number, error: Schema.String }, + ClickedIssue: { id: Schema.Number }, + ClickedRetry: {}, + SucceededFetchIssue: { seq: Schema.Number, detail: Schema.NullOr(Detail) }, + FailedFetchIssue: { seq: Schema.Number, error: Schema.String }, + SucceededFetchComments: { seq: Schema.Number, comments: Schema.Array(Comment) }, + FailedFetchComments: { seq: Schema.Number, error: Schema.String }, + ClickedAssign: {}, + SucceededAssign: { seq: Schema.Number, id: Schema.Number, assignee: Schema.String }, + FailedAssign: { seq: Schema.Number, id: Schema.Number, error: Schema.String }, + UpdatedTitleInput: { value: Schema.String }, + ClickedRename: {}, + SucceededRename: { seq: Schema.Number, id: Schema.Number, title: Schema.String }, + FailedRename: { seq: Schema.Number, id: Schema.Number, error: Schema.String }, + ClickedClose: {}, + SucceededClose: { seq: Schema.Number, id: Schema.Number }, + FailedClose: { seq: Schema.Number, id: Schema.Number, error: Schema.String }, + UpdatedCommentInput: { value: Schema.String }, + ClickedPost: {}, + SucceededPost: { seq: Schema.Number, id: Schema.Number, comment: Comment }, + FailedPost: { seq: Schema.Number, id: Schema.Number, error: Schema.String }, +}) +export type Message = typeof Message.Type + +// COMMAND + +const FetchIssues = Command.define('FetchIssues', { + args: { seq: Schema.Number, filter: Filter }, + messages: [Message.SucceededFetchIssues, Message.FailedFetchIssues], + execute: ({ seq, filter }) => + request(() => Api.fetchIssues(filter)).pipe( + Effect.map(issues => Message.SucceededFetchIssues({ seq, issues })), + Effect.catch(error => Effect.succeed(Message.FailedFetchIssues({ seq, error }))), + ), +}) + +const FetchIssue = Command.define('FetchIssue', { + args: { seq: Schema.Number, id: Schema.Number }, + messages: [Message.SucceededFetchIssue, Message.FailedFetchIssue], + execute: ({ seq, id }) => + request(() => Api.fetchIssue(id)).pipe( + Effect.map(detail => Message.SucceededFetchIssue({ seq, detail })), + Effect.catch(error => Effect.succeed(Message.FailedFetchIssue({ seq, error }))), + ), +}) + +const FetchComments = Command.define('FetchComments', { + args: { seq: Schema.Number, id: Schema.Number }, + messages: [Message.SucceededFetchComments, Message.FailedFetchComments], + execute: ({ seq, id }) => + request(() => Api.fetchComments(id)).pipe( + Effect.map(comments => Message.SucceededFetchComments({ seq, comments })), + Effect.catch(error => Effect.succeed(Message.FailedFetchComments({ seq, error }))), + ), +}) + +const Assign = Command.define('Assign', { + args: { seq: Schema.Number, id: Schema.Number, user: Schema.String }, + messages: [Message.SucceededAssign, Message.FailedAssign], + execute: ({ seq, id, user }) => + request(() => Api.assign(id, user)).pipe( + Effect.map(answer => Message.SucceededAssign({ seq, id, assignee: answer.assignee })), + Effect.catch(error => Effect.succeed(Message.FailedAssign({ seq, id, error }))), + ), +}) + +const Rename = Command.define('Rename', { + args: { seq: Schema.Number, id: Schema.Number, title: Schema.String }, + messages: [Message.SucceededRename, Message.FailedRename], + execute: ({ seq, id, title }) => + request(() => Api.renameIssue(id, title)).pipe( + Effect.map(answer => Message.SucceededRename({ seq, id, title: answer.title })), + Effect.catch(error => Effect.succeed(Message.FailedRename({ seq, id, error }))), + ), +}) + +const Close = Command.define('Close', { + args: { seq: Schema.Number, id: Schema.Number }, + messages: [Message.SucceededClose, Message.FailedClose], + execute: ({ seq, id }) => + request(() => Api.closeIssue(id)).pipe( + Effect.as(Message.SucceededClose({ seq, id })), + Effect.catch(error => Effect.succeed(Message.FailedClose({ seq, id, error }))), + ), +}) + +const Post = Command.define('Post', { + args: { seq: Schema.Number, id: Schema.Number, author: Schema.String, text: Schema.String }, + messages: [Message.SucceededPost, Message.FailedPost], + execute: ({ seq, id, author, text }) => + request(() => Api.addComment(id, author, text)).pipe( + Effect.map(comment => Message.SucceededPost({ seq, id, comment })), + Effect.catch(error => Effect.succeed(Message.FailedPost({ seq, id, error }))), + ), +}) + +// INIT + +export const init = (): Update.Return => ({ + model: { + user: 'guest', + filter: 'open', + listSeq: 1, + list: ListState.Loading(), + maybeSelected: Option.none(), + detailSeq: 0, + detail: DetailState.Loading(), + comments: CommentsState.Loading(), + seq: 1, + assigning: [], + confirmedAssigns: [], + lastAssign: 0, + maybeAssignError: Option.none(), + titleInput: '', + renaming: [], + confirmedRenames: [], + lastRename: 0, + maybeRenameError: Option.none(), + closing: [], + lastClose: 0, + maybeCloseError: Option.none(), + commentInput: '', + sending: [], + lastPost: 0, + maybePostError: Option.none(), + }, + commands: [FetchIssues({ seq: 1, filter: 'open' })], +}) + +// UPDATE + +const dropGuess = + (seq: number) => +
    (guesses: ReadonlyArray): ReadonlyArray => + Array.filter(guesses, guess => guess.seq !== seq) + +const latestGuess = (guesses: ReadonlyArray, id: number): Option.Option => + pipe( + guesses, + Array.filter(guess => guess.id === id), + Array.last, + Option.map(guess => guess.value), + ) + +const confirmedSeq = (confirmed: ReadonlyArray, id: number): number => + pipe( + confirmed, + Array.findFirst(entry => entry.id === id), + Option.match({ onNone: () => 0, onSome: entry => entry.seq }), + ) + +const confirm = (confirmed: ReadonlyArray, id: number, seq: number): ReadonlyArray => [ + ...Array.filter(confirmed, entry => entry.id !== id), + { id, seq }, +] + +const mapIssue = (id: number, f: (issue: Issue) => Issue) => (list: typeof ListState.Type) => + ListState.match(list, { + Loading: () => list, + Failed: () => list, + Loaded: ({ issues }) => ListState.Loaded({ issues: Array.map(issues, issue => (issue.id === id ? f(issue) : issue)) }), + }) + +const isSelected = (model: Model, id: number) => Option.contains(model.maybeSelected, id) + +// Only the newest write of a kind reports its failure. +const failure = (last: number, seq: number, id: number, message: string) => (current: Option.Option) => + last === seq ? Option.some({ id, message }) : current + +export const update = (model: Model, message: Message) => + Message.match>(message, { + UpdatedUser: ({ value }) => ({ model: modifyFields(model, { user: () => value }) }), + + ClickedFilter: ({ filter }) => { + if (filter === model.filter) { + return { model } + } + const listSeq = model.listSeq + 1 + return { + model: modifyFields(model, { filter: () => filter, listSeq: () => listSeq, list: () => ListState.Loading() }), + commands: [FetchIssues({ seq: listSeq, filter })], + } + }, + + SucceededFetchIssues: ({ seq, issues }) => + seq === model.listSeq ? { model: modifyFields(model, { list: () => ListState.Loaded({ issues }) }) } : { model }, + + FailedFetchIssues: ({ seq, error }) => + seq === model.listSeq ? { model: modifyFields(model, { list: () => ListState.Failed({ error }) }) } : { model }, + + ClickedIssue: ({ id }) => { + const cleared = modifyFields(model, { + titleInput: () => '', + maybeAssignError: () => Option.none(), + maybeRenameError: () => Option.none(), + maybeCloseError: () => Option.none(), + maybePostError: () => Option.none(), + }) + if (isSelected(model, id)) { + return { model: cleared } + } + const detailSeq = model.detailSeq + 1 + return { + model: modifyFields(cleared, { + maybeSelected: () => Option.some(id), + detailSeq: () => detailSeq, + detail: () => DetailState.Loading(), + comments: () => CommentsState.Loading(), + }), + commands: [FetchIssue({ seq: detailSeq, id }), FetchComments({ seq: detailSeq, id })], + } + }, + + ClickedRetry: () => + Option.match(model.maybeSelected, { + onNone: () => ({ model }), + onSome: id => { + const detailSeq = model.detailSeq + 1 + return { + model: modifyFields(model, { detailSeq: () => detailSeq, detail: () => DetailState.Loading() }), + commands: [FetchIssue({ seq: detailSeq, id })], + } + }, + }), + + SucceededFetchIssue: ({ seq, detail }) => { + if (seq !== model.detailSeq) { + return { model } + } + const id = Option.getOrElse(model.maybeSelected, () => 0) + return { + model: modifyFields(model, { + detail: () => + detail === null ? DetailState.Failed({ error: `issue #${id} not found` }) : DetailState.Loaded({ detail }), + }), + } + }, + + FailedFetchIssue: ({ seq, error }) => + seq === model.detailSeq ? { model: modifyFields(model, { detail: () => DetailState.Failed({ error }) }) } : { model }, + + SucceededFetchComments: ({ seq, comments }) => + seq === model.detailSeq + ? { model: modifyFields(model, { comments: () => CommentsState.Loaded({ comments }) }) } + : { model }, + + FailedFetchComments: ({ seq, error }) => + seq === model.detailSeq ? { model: modifyFields(model, { comments: () => CommentsState.Failed({ error }) }) } : { model }, + + ClickedAssign: () => + Option.match(model.maybeSelected, { + onNone: () => ({ model }), + onSome: id => ({ + model: modifyFields(model, { + seq: seq => seq + 1, + lastAssign: () => model.seq, + maybeAssignError: () => Option.none(), + assigning: Array.append({ seq: model.seq, id, value: model.user }), + }), + commands: [Assign({ seq: model.seq, id, user: model.user })], + }), + }), + + SucceededAssign: ({ seq, id, assignee }) => { + const isNewest = confirmedSeq(model.confirmedAssigns, id) < seq + return { + model: modifyFields(model, { + assigning: dropGuess(seq), + confirmedAssigns: confirmed => (isNewest ? confirm(confirmed, id, seq) : confirmed), + list: list => (isNewest ? mapIssue(id, issue => ({ ...issue, assignee }))(list) : list), + }), + } + }, + + FailedAssign: ({ seq, id, error }) => ({ + model: modifyFields(model, { + assigning: dropGuess(seq), + maybeAssignError: failure(model.lastAssign, seq, id, error), + }), + }), + + UpdatedTitleInput: ({ value }) => ({ model: modifyFields(model, { titleInput: () => value }) }), + + ClickedRename: () => + Option.match(model.maybeSelected, { + onNone: () => ({ model }), + onSome: id => + model.titleInput === '' + ? { model } + : { + model: modifyFields(model, { + seq: seq => seq + 1, + lastRename: () => model.seq, + maybeRenameError: () => Option.none(), + renaming: Array.append({ seq: model.seq, id, value: model.titleInput }), + }), + commands: [Rename({ seq: model.seq, id, title: model.titleInput })], + }, + }), + + SucceededRename: ({ seq, id, title }) => { + const isNewest = confirmedSeq(model.confirmedRenames, id) < seq + return { + model: modifyFields(model, { + renaming: dropGuess(seq), + confirmedRenames: confirmed => (isNewest ? confirm(confirmed, id, seq) : confirmed), + list: list => (isNewest ? mapIssue(id, issue => ({ ...issue, title }))(list) : list), + detail: detail => + isNewest && detail._tag === 'Loaded' && detail.detail.id === id + ? DetailState.Loaded({ detail: { ...detail.detail, title } }) + : detail, + }), + } + }, + + FailedRename: ({ seq, id, error }) => ({ + model: modifyFields(model, { + renaming: dropGuess(seq), + maybeRenameError: failure(model.lastRename, seq, id, error), + }), + }), + + ClickedClose: () => + Option.match(model.maybeSelected, { + onNone: () => ({ model }), + onSome: id => ({ + model: modifyFields(model, { + seq: seq => seq + 1, + lastClose: () => model.seq, + maybeCloseError: () => Option.none(), + closing: Array.append({ seq: model.seq, id }), + }), + commands: [Close({ seq: model.seq, id })], + }), + }), + + SucceededClose: ({ seq, id }) => ({ + model: modifyFields(model, { + closing: dropGuess(seq), + list: list => + model.filter === 'open' && list._tag === 'Loaded' + ? ListState.Loaded({ issues: Array.filter(list.issues, issue => issue.id !== id) }) + : list, + }), + }), + + FailedClose: ({ seq, id, error }) => ({ + model: modifyFields(model, { + closing: dropGuess(seq), + maybeCloseError: failure(model.lastClose, seq, id, error), + }), + }), + + UpdatedCommentInput: ({ value }) => ({ model: modifyFields(model, { commentInput: () => value }) }), + + ClickedPost: () => + Option.match(model.maybeSelected, { + onNone: () => ({ model }), + onSome: id => + model.commentInput === '' + ? { model } + : { + model: modifyFields(model, { + seq: seq => seq + 1, + lastPost: () => model.seq, + maybePostError: () => Option.none(), + commentInput: () => '', + sending: Array.append({ seq: model.seq, id, author: model.user, text: model.commentInput }), + }), + commands: [Post({ seq: model.seq, id, author: model.user, text: model.commentInput })], + }, + }), + + SucceededPost: ({ seq, id, comment }) => ({ + model: modifyFields(model, { + sending: dropGuess(seq), + comments: comments => + isSelected(model, id) && comments._tag === 'Loaded' + ? CommentsState.Loaded({ comments: Array.append(comments.comments, comment) }) + : comments, + }), + }), + + FailedPost: ({ seq, id, error }) => ({ + model: modifyFields(model, { + sending: dropGuess(seq), + maybePostError: failure(model.lastPost, seq, id, error), + }), + }), + }) + +// VIEW + +const testId = (h: HtmlBuilder, id: string) => h.DataAttribute('testid', id) + +const shownIssues = (model: Model, issues: ReadonlyArray): ReadonlyArray => + pipe( + issues, + Array.filter(issue => !(model.filter === 'open' && Array.some(model.closing, closing => closing.id === issue.id))), + Array.map(issue => ({ + ...issue, + title: Option.getOrElse(latestGuess(model.renaming, issue.id), () => issue.title), + assignee: Option.getOrElse(latestGuess(model.assigning, issue.id), () => issue.assignee), + })), + ) + +const listView = (model: Model, h: HtmlBuilder): Html => + ListState.match(model.list, { + Loading: () => h.p([], [`loading ${model.filter} issues`]), + Failed: ({ error }) => h.p([], [`list error: ${error}`]), + Loaded: ({ issues }) => + h.ul( + [], + Array.map(shownIssues(model, issues), issue => + h.keyed('li')(String(issue.id), [], [ + h.button( + [testId(h, 'issue'), h.OnClick(Message.ClickedIssue({ id: issue.id }))], + [`#${issue.id} ${issue.title} (${issue.assignee ?? 'unassigned'})`], + ), + ]), + ), + ), + }) + +const errorLine = (model: Model, maybeError: Option.Option, prefix: string, h: HtmlBuilder): Html => + pipe( + maybeError, + Option.filter(error => isSelected(model, error.id)), + Option.match({ onNone: () => h.empty, onSome: error => h.p([], [`${prefix}${error.message}`]) }), + ) + +const assignErrorLine = (model: Model, h: HtmlBuilder): Html => + pipe( + model.maybeAssignError, + Option.filter(error => isSelected(model, error.id)), + Option.match({ onNone: () => h.empty, onSome: error => h.p([testId(h, 'assign-error')], [error.message]) }), + ) + +const commentsView = (model: Model, id: number, h: HtmlBuilder): Html => + CommentsState.match(model.comments, { + Loading: () => h.p([], ['loading comments']), + Failed: ({ error }) => h.p([], [`comments error: ${error}`]), + Loaded: ({ comments }) => + h.ul([], [ + ...Array.map(comments, comment => h.li([testId(h, 'comment')], [`${comment.author}: ${comment.text}`])), + ...pipe( + model.sending, + Array.filter(sending => sending.id === id), + Array.map(sending => + h.keyed('li')(`s${sending.seq}`, [testId(h, 'comment')], [`${sending.author}: ${sending.text} (sending…)`]), + ), + ), + ]), + }) + +const detailBody = (model: Model, id: number, h: HtmlBuilder): Html => + DetailState.match(model.detail, { + Loading: () => h.p([], [`loading #${id}`]), + Failed: ({ error }) => h.p([], [`detail error: ${error}`]), + Loaded: ({ detail }) => + h.div([], [ + h.h2([], [`#${detail.id} ${Option.getOrElse(latestGuess(model.renaming, detail.id), () => detail.title)}`]), + h.p([], [detail.body]), + ]), + }) + +const detailView = (model: Model, id: number, h: HtmlBuilder): ReadonlyArray => [ + detailBody(model, id, h), + h.button([testId(h, 'retry'), h.OnClick(Message.ClickedRetry())], ['retry']), + h.button([testId(h, 'assign'), h.OnClick(Message.ClickedAssign())], [`assign to ${model.user}`]), + assignErrorLine(model, h), + h.div([], [ + h.input([ + testId(h, 'title-input'), + h.Value(model.titleInput), + h.OnInput(value => Message.UpdatedTitleInput({ value })), + ]), + h.button([testId(h, 'rename'), h.OnClick(Message.ClickedRename())], ['rename']), + errorLine(model, model.maybeRenameError, 'rename error: ', h), + ]), + h.button([testId(h, 'close'), h.OnClick(Message.ClickedClose())], ['close']), + errorLine(model, model.maybeCloseError, 'close error: ', h), + h.div([testId(h, 'comments')], [commentsView(model, id, h)]), + h.input([ + testId(h, 'comment-input'), + h.Value(model.commentInput), + h.OnInput(value => Message.UpdatedCommentInput({ value })), + ]), + h.button([testId(h, 'post'), h.OnClick(Message.ClickedPost())], ['post']), + errorLine(model, model.maybePostError, 'post error: ', h), +] + +export const view = Submodel.defineView((model, h) => + h.div([], [ + h.section([], [ + h.label([], [ + 'user ', + h.input([testId(h, 'user'), h.Value(model.user), h.OnInput(value => Message.UpdatedUser({ value }))]), + ]), + h.div([], [ + h.button([testId(h, 'filter-open'), h.OnClick(Message.ClickedFilter({ filter: 'open' }))], ['open']), + h.button([testId(h, 'filter-closed'), h.OnClick(Message.ClickedFilter({ filter: 'closed' }))], ['closed']), + ]), + h.div([testId(h, 'list')], [listView(model, h)]), + ]), + h.section( + [testId(h, 'detail')], + Option.match(model.maybeSelected, { + onNone: () => [h.p([], ['select an issue'])], + onSome: id => detailView(model, id, h), + }), + ), + ]), +) diff --git a/eval/reference/desk/foldkit/main.ts b/eval/reference/desk/foldkit/main.ts new file mode 100644 index 0000000..5dba581 --- /dev/null +++ b/eval/reference/desk/foldkit/main.ts @@ -0,0 +1,130 @@ +import { Schema } from 'effect' +import { Command, type Runtime, type Update } from 'foldkit' +import { Document, Html, HtmlBuilder } from 'foldkit/html' +import { defineMessageUnion } from 'foldkit/message' +import { defineTaggedUnion } from 'foldkit/schema' + +import * as Help from './help' +import * as Issues from './issues' +import * as Settings from './settings' + +// MODEL + +const Tab = Schema.Literals(['issues', 'help', 'settings']) +type Tab = typeof Tab.Type + +// Each opening of a page gets a new `pageId`. Answers to a page that was left carry an old one and are dropped. +export const Page = defineTaggedUnion({ + Issues: { model: Issues.Model }, + Help: { model: Help.Model }, + Settings: { model: Settings.Model }, +}) + +export const Model = Schema.Struct({ pageId: Schema.Number, page: Page }) +export type Model = typeof Model.Type + +// MESSAGE + +export const Message = defineMessageUnion({ + ClickedTab: { tab: Tab }, + GotIssuesMessage: { pageId: Schema.Number, message: Issues.Message }, + GotHelpMessage: { pageId: Schema.Number, message: Help.Message }, + GotSettingsMessage: { pageId: Schema.Number, message: Settings.Message }, +}) +export type Message = typeof Message.Type + +// INIT + +const openIssues = (pageId: number, result: Update.Return): Update.Return => ({ + model: { pageId, page: Page.Issues({ model: result.model }) }, + commands: Command.mapMessages(result.commands, message => Message.GotIssuesMessage({ pageId, message })), +}) + +const openHelp = (pageId: number, result: Update.Return): Update.Return => ({ + model: { pageId, page: Page.Help({ model: result.model }) }, + commands: Command.mapMessages(result.commands, message => Message.GotHelpMessage({ pageId, message })), +}) + +const openSettings = ( + pageId: number, + result: Update.Return, +): Update.Return => ({ + model: { pageId, page: Page.Settings({ model: result.model }) }, + commands: Command.mapMessages(result.commands, message => Message.GotSettingsMessage({ pageId, message })), +}) + +export const init: Runtime.ApplicationInit = () => openIssues(1, Issues.init()) + +// UPDATE + +const tabOf = (page: typeof Page.Type): Tab => + Page.match(page, { Issues: () => 'issues', Help: () => 'help', Settings: () => 'settings' }) + +export const update = (model: Model, message: Message) => + Message.match>(message, { + ClickedTab: ({ tab }) => { + if (tab === tabOf(model.page)) { + return { model } + } + const pageId = model.pageId + 1 + return tab === 'issues' + ? openIssues(pageId, Issues.init()) + : tab === 'help' + ? openHelp(pageId, Help.init()) + : openSettings(pageId, Settings.init()) + }, + + GotIssuesMessage: ({ pageId, message }) => + model.page._tag === 'Issues' && pageId === model.pageId + ? openIssues(pageId, Issues.update(model.page.model, message)) + : { model }, + + GotHelpMessage: ({ pageId, message }) => + model.page._tag === 'Help' && pageId === model.pageId + ? openHelp(pageId, Help.update(model.page.model, message)) + : { model }, + + GotSettingsMessage: ({ pageId, message }) => + model.page._tag === 'Settings' && pageId === model.pageId + ? openSettings(pageId, Settings.update(model.page.model, message)) + : { model }, + }) + +// VIEW + +const pageView = (model: Model, h: HtmlBuilder): Html => + Page.match(model.page, { + Issues: page => + h.submodel({ + slotId: `issues-${model.pageId}`, + model: page.model, + view: Issues.view, + toParentMessage: message => Message.GotIssuesMessage({ pageId: model.pageId, message }), + }), + Help: page => + h.submodel({ + slotId: `help-${model.pageId}`, + model: page.model, + view: Help.view, + toParentMessage: message => Message.GotHelpMessage({ pageId: model.pageId, message }), + }), + Settings: page => + h.submodel({ + slotId: `settings-${model.pageId}`, + model: page.model, + view: Settings.view, + toParentMessage: message => Message.GotSettingsMessage({ pageId: model.pageId, message }), + }), + }) + +export const view = (model: Model, h: HtmlBuilder): Document => ({ + title: 'Issue desk', + body: h.div([], [ + h.nav([], [ + h.button([h.DataAttribute('testid', 'tab-issues'), h.OnClick(Message.ClickedTab({ tab: 'issues' }))], ['Issues']), + h.button([h.DataAttribute('testid', 'tab-help'), h.OnClick(Message.ClickedTab({ tab: 'help' }))], ['Help']), + h.button([h.DataAttribute('testid', 'tab-settings'), h.OnClick(Message.ClickedTab({ tab: 'settings' }))], ['Settings']), + ]), + pageView(model, h), + ]), +}) diff --git a/eval/reference/desk/foldkit/request.ts b/eval/reference/desk/foldkit/request.ts new file mode 100644 index 0000000..d744b31 --- /dev/null +++ b/eval/reference/desk/foldkit/request.ts @@ -0,0 +1,7 @@ +import { Effect } from 'effect' + +export const errorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error) + +export const request = (call: () => Promise): Effect.Effect => + Effect.tryPromise({ try: call, catch: errorMessage }) diff --git a/eval/reference/desk/foldkit/settings.ts b/eval/reference/desk/foldkit/settings.ts new file mode 100644 index 0000000..01f5807 --- /dev/null +++ b/eval/reference/desk/foldkit/settings.ts @@ -0,0 +1,201 @@ +import { Array, Effect, Option, Schema, pipe } from 'effect' +import { Command, Submodel, type Update } from 'foldkit' +import { Html, HtmlBuilder } from 'foldkit/html' +import { defineMessageUnion } from 'foldkit/message' +import { defineTaggedUnion } from 'foldkit/schema' +import { modifyFields } from 'foldkit/struct' + +import * as Api from './api' +import { request } from './request' + +// MODEL + +const Theme = Schema.Literals(['light', 'dark']) +const Density = Schema.Literals(['comfortable', 'compact']) +const Settings = Schema.Struct({ name: Schema.String, theme: Theme, density: Density }) +type Settings = typeof Settings.Type + +// The draft is seeded once from the loaded settings; after that it is the user's. +export const LoadState = defineTaggedUnion({ + Loading: {}, + Failed: { error: Schema.String }, + Loaded: { saved: Settings, draft: Settings }, +}) + +export const Model = Schema.Struct({ + loadSeq: Schema.Number, + load: LoadState, + seq: Schema.Number, + saving: Schema.Array(Schema.Struct({ seq: Schema.Number, value: Settings })), + confirmed: Schema.Number, + maybeSaveError: Schema.Option(Schema.String), +}) +export type Model = typeof Model.Type + +// MESSAGE + +export const Message = defineMessageUnion({ + SucceededLoad: { seq: Schema.Number, settings: Settings }, + FailedLoad: { seq: Schema.Number, error: Schema.String }, + ClickedRetryLoad: {}, + UpdatedName: { value: Schema.String }, + ClickedTheme: { theme: Theme }, + ClickedDensityToggle: {}, + ClickedSave: {}, + SucceededSave: { seq: Schema.Number, settings: Settings }, + FailedSave: { seq: Schema.Number, error: Schema.String }, +}) +export type Message = typeof Message.Type + +// COMMAND + +const Load = Command.define('Load', { + args: { seq: Schema.Number }, + messages: [Message.SucceededLoad, Message.FailedLoad], + execute: ({ seq }) => + request(() => Api.loadSettings()).pipe( + Effect.map(settings => Message.SucceededLoad({ seq, settings })), + Effect.catch(error => Effect.succeed(Message.FailedLoad({ seq, error }))), + ), +}) + +const Save = Command.define('Save', { + args: { seq: Schema.Number, settings: Settings }, + messages: [Message.SucceededSave, Message.FailedSave], + execute: ({ seq, settings }) => + request(() => Api.saveSettings(settings)).pipe( + Effect.map(saved => Message.SucceededSave({ seq, settings: saved })), + Effect.catch(error => Effect.succeed(Message.FailedSave({ seq, error }))), + ), +}) + +// INIT + +export const init = (): Update.Return => ({ + model: { loadSeq: 1, load: LoadState.Loading(), seq: 1, saving: [], confirmed: 0, maybeSaveError: Option.none() }, + commands: [Load({ seq: 1 })], +}) + +// UPDATE + +const nameError = (name: string): string => + name.length === 0 + ? 'Display name is required' + : name.length > 20 + ? `Display name must be at most 20 characters, not ${name.length}` + : '' + +const editDraft = (model: Model, f: (draft: Settings) => Settings): Model => + modifyFields(model, { + load: load => (load._tag === 'Loaded' ? LoadState.Loaded({ saved: load.saved, draft: f(load.draft) }) : load), + }) + +export const update = (model: Model, message: Message) => + Message.match>(message, { + SucceededLoad: ({ seq, settings }) => + seq === model.loadSeq + ? { model: modifyFields(model, { load: () => LoadState.Loaded({ saved: settings, draft: settings }) }) } + : { model }, + + FailedLoad: ({ seq, error }) => + seq === model.loadSeq ? { model: modifyFields(model, { load: () => LoadState.Failed({ error }) }) } : { model }, + + ClickedRetryLoad: () => { + const loadSeq = model.loadSeq + 1 + return { model: modifyFields(model, { loadSeq: () => loadSeq }), commands: [Load({ seq: loadSeq })] } + }, + + UpdatedName: ({ value }) => ({ model: editDraft(model, draft => ({ ...draft, name: value })) }), + + ClickedTheme: ({ theme }) => ({ model: editDraft(model, draft => ({ ...draft, theme })) }), + + ClickedDensityToggle: () => ({ + model: editDraft(model, draft => ({ ...draft, density: draft.density === 'compact' ? 'comfortable' : 'compact' })), + }), + + ClickedSave: () => { + if (model.load._tag !== 'Loaded' || nameError(model.load.draft.name) !== '') { + return { model } + } + const draft = model.load.draft + return { + model: modifyFields(model, { + seq: seq => seq + 1, + maybeSaveError: () => Option.none(), + saving: Array.append({ seq: model.seq, value: draft }), + }), + commands: [Save({ seq: model.seq, settings: draft })], + } + }, + + SucceededSave: ({ seq, settings }) => { + const isNewest = seq > model.confirmed + return { + model: modifyFields(model, { + saving: Array.filter(saving => saving.seq !== seq), + confirmed: confirmed => (isNewest ? seq : confirmed), + load: load => + isNewest && load._tag === 'Loaded' ? LoadState.Loaded({ saved: settings, draft: load.draft }) : load, + }), + } + }, + + FailedSave: ({ seq, error }) => ({ + model: modifyFields(model, { + saving: Array.filter(saving => saving.seq !== seq), + maybeSaveError: () => Option.some(error), + }), + }), + }) + +// VIEW + +const testId = (h: HtmlBuilder, id: string) => h.DataAttribute('testid', id) + +const loadedView = (model: Model, saved: Settings, draft: Settings, h: HtmlBuilder): Html => { + const maybeGuess = pipe( + model.saving, + Array.filter(saving => saving.seq > model.confirmed), + Array.last, + ) + const shown = Option.match(maybeGuess, { onNone: () => saved, onSome: guess => guess.value }) + const error = nameError(draft.name) + return h.main([], [ + h.p( + [testId(h, 'saved')], + [`Saved: ${shown.name} · ${shown.theme} · ${shown.density}${Option.isSome(maybeGuess) ? ' (saving…)' : ''}`], + ), + Option.match(model.maybeSaveError, { + onNone: () => h.empty, + onSome: saveError => h.p([testId(h, 'save-error')], [`Save failed: ${saveError}`]), + }), + h.div([], [ + h.label([], [ + 'Display name ', + h.input([testId(h, 'name'), h.Value(draft.name), h.OnInput(value => Message.UpdatedName({ value }))]), + ]), + h.small([testId(h, 'name-count')], [`${draft.name.length}/20`]), + error === '' ? h.empty : h.p([testId(h, 'name-error')], [error]), + h.div([], [ + h.span([], [`Theme: ${draft.theme}`]), + h.button([testId(h, 'theme-light'), h.OnClick(Message.ClickedTheme({ theme: 'light' }))], ['light']), + h.button([testId(h, 'theme-dark'), h.OnClick(Message.ClickedTheme({ theme: 'dark' }))], ['dark']), + h.button([testId(h, 'density-toggle'), h.OnClick(Message.ClickedDensityToggle())], ['Toggle density']), + h.p([testId(h, 'preview')], [`Preview: ${draft.theme} / ${draft.density}`]), + ]), + h.button([testId(h, 'save'), h.OnClick(Message.ClickedSave())], ['Save']), + ]), + ]) +} + +export const view = Submodel.defineView((model, h) => + LoadState.match(model.load, { + Loading: () => h.p([testId(h, 'load')], ['Loading settings…']), + Failed: ({ error }) => + h.section([], [ + h.p([testId(h, 'load')], [`Could not load settings: ${error}`]), + h.button([testId(h, 'retry-load'), h.OnClick(Message.ClickedRetryLoad())], ['Retry']), + ]), + Loaded: ({ saved, draft }) => loadedView(model, saved, draft, h), + }), +) diff --git a/eval/reference/desk/react/App.tsx b/eval/reference/desk/react/App.tsx new file mode 100644 index 0000000..f36876b --- /dev/null +++ b/eval/reference/desk/react/App.tsx @@ -0,0 +1,42 @@ +import { useState, type ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import IssuesPage from "./IssuesPage"; +import HelpPage from "./HelpPage"; +import SettingsPage from "./SettingsPage"; + +type Page = "issues" | "help" | "settings"; + +// Each page gets its own query cache, made when the page opens, so leaving a page discards what it loaded. +function PageClient({ children }: { children: ReactNode }) { + const [client] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, refetchOnWindowFocus: false, refetchOnReconnect: false }, + }, + }), + ); + return {children}; +} + +export default function App() { + const [page, setPage] = useState("issues"); + return ( +
    + + + {page === "issues" ? : page === "help" ? : } + +
    + ); +} diff --git a/eval/reference/desk/react/CommentList.tsx b/eval/reference/desk/react/CommentList.tsx new file mode 100644 index 0000000..3218198 --- /dev/null +++ b/eval/reference/desk/react/CommentList.tsx @@ -0,0 +1,30 @@ +import type { Comment } from "./api"; +import { errorMessage } from "./errors"; + +export type Sending = { seq: number; issueId: number; author: string; text: string }; + +type Props = { + comments: Comment[] | undefined; + isPending: boolean; + error: unknown; + sending: Sending[]; +}; + +export default function CommentList({ comments, isPending, error, sending }: Props) { + if (isPending) return

    loading comments

    ; + if (error || !comments) return

    comments error: {errorMessage(error)}

    ; + return ( +
      + {comments.map((c, i) => ( +
    • + {c.author}: {c.text} +
    • + ))} + {sending.map(p => ( +
    • + {p.author}: {p.text} (sending…) +
    • + ))} +
    + ); +} diff --git a/eval/reference/desk/react/HelpPage.tsx b/eval/reference/desk/react/HelpPage.tsx new file mode 100644 index 0000000..020d0ed --- /dev/null +++ b/eval/reference/desk/react/HelpPage.tsx @@ -0,0 +1,84 @@ +import { useState } from "react"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { fetchItem, search, type Hit } from "./api"; + +export default function HelpPage() { + const [query, setQuery] = useState(""); + const [selected, setSelected] = useState(null); + return ( +
    + setQuery(e.target.value)} />{" "} + {query !== "" && }{" "} + +
    + ); +} + +function Results({ query, selected, onSelect }: { query: string; selected: number | null; onSelect: (id: number) => void }) { + const tooShort = query.length < 2; + const { data, error, isFetching } = useQuery({ + queryKey: ["search", query], + queryFn: () => search(query), + enabled: !tooShort, + placeholderData: keepPreviousData, + }); + if (tooShort) + return ( +
    +

    too short: {query}

    +
    + ); + return ( +
    + {isFetching &&

    searching…

    } + {error && !isFetching ? ( +

    search failed: {error.message}

    + ) : ( + data && ( +
    +

    + {data.length} results for {query} +

    +
      + {data.map((hit: Hit) => ( +
    • onSelect(hit.id)}> + {hit.id === selected ? "> " : ""} + {hit.name} +
    • + ))} +
    +
    + ) + )} +
    + ); +} + +function HelpItem({ id }: { id: number | null }) { + const { data, error, isPending, refetch } = useQuery({ + queryKey: ["item", id], + queryFn: () => fetchItem(id!), + enabled: id !== null, + }); + if (id === null) return

    pick a result

    ; + return ( +
    + {isPending ? ( +

    loading…

    + ) : error ? ( + <> +

    failed: {error.message}

    {" "} + + + ) : data === null ? ( +

    item {id} not found

    + ) : ( +
    +

    {data.name}

    {data.blurb}

    +
    + )} +
    + ); +} diff --git a/eval/reference/desk/react/IssueDetail.tsx b/eval/reference/desk/react/IssueDetail.tsx new file mode 100644 index 0000000..71d00fe --- /dev/null +++ b/eval/reference/desk/react/IssueDetail.tsx @@ -0,0 +1,74 @@ +import type { Comment, IssueDetail as Detail } from "./api"; +import { errorMessage } from "./errors"; +import CommentList, { type Sending } from "./CommentList"; + +type Props = { + id: number; + user: string; + detail: Detail | undefined; + loading: boolean; + error: unknown; + onRetry: () => void; + onAssign: () => void; + assignError: string | null; + titleInput: string; + onTitleInput: (title: string) => void; + onRename: () => void; + renameError: string | null; + onClose: () => void; + closeError: string | null; + comments: Comment[] | undefined; + commentsPending: boolean; + commentsError: unknown; + sending: Sending[]; + commentInput: string; + onCommentInput: (text: string) => void; + onPost: () => void; + postError: string | null; +}; + +export default function IssueDetail(p: Props) { + let body; + if (p.loading) body =

    loading #{p.id}

    ; + else if (p.error || !p.detail) body =

    detail error: {errorMessage(p.error)}

    ; + else + body = ( + <> +

    + #{p.detail.id} {p.detail.title} +

    +

    {p.detail.body}

    + + ); + return ( + <> + {body} + + + {p.assignError &&

    {p.assignError}

    } +
    + p.onTitleInput(e.target.value)} /> + + {p.renameError &&

    rename error: {p.renameError}

    } +
    + + {p.closeError &&

    close error: {p.closeError}

    } +
    + +
    + p.onCommentInput(e.target.value)} /> + + {p.postError &&

    post error: {p.postError}

    } + + ); +} diff --git a/eval/reference/desk/react/IssueList.tsx b/eval/reference/desk/react/IssueList.tsx new file mode 100644 index 0000000..576ad8b --- /dev/null +++ b/eval/reference/desk/react/IssueList.tsx @@ -0,0 +1,27 @@ +import type { Issue } from "./api"; +import { errorMessage } from "./errors"; + +type Props = { + filter: "open" | "closed"; + issues: Issue[] | undefined; + isPending: boolean; + error: unknown; + selected: number | null; + onSelect: (id: number) => void; +}; + +export default function IssueList({ filter, issues, isPending, error, selected, onSelect }: Props) { + if (isPending) return

    loading {filter} issues

    ; + if (error || !issues) return

    list error: {errorMessage(error)}

    ; + return ( +
      + {issues.map(issue => ( +
    • + +
    • + ))} +
    + ); +} diff --git a/eval/reference/desk/react/IssuesPage.tsx b/eval/reference/desk/react/IssuesPage.tsx new file mode 100644 index 0000000..9e16523 --- /dev/null +++ b/eval/reference/desk/react/IssuesPage.tsx @@ -0,0 +1,233 @@ +import { useRef, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { addComment, assign, closeIssue, fetchComments, fetchIssue, fetchIssues, renameIssue } from "./api"; +import type { Issue, IssueDetail as Detail } from "./api"; +import { errorMessage, type IssueError } from "./errors"; +import IssueList from "./IssueList"; +import IssueDetail from "./IssueDetail"; +import type { Sending } from "./CommentList"; + +type Filter = "open" | "closed"; +// a write still in the air: `seq` orders writes to the same issue, the latest one is what the issue shows +type Guess = { seq: number; value: T }; +type Guesses = Record[]>; + +const latest = (guesses: Guesses, id: number): T | undefined => guesses[id]?.at(-1)?.value; +const addGuess = (prev: Guesses, id: number, g: Guess): Guesses => ({ ...prev, [id]: [...(prev[id] ?? []), g] }); +const dropGuess = (prev: Guesses, id: number, seq: number): Guesses => { + const rest = (prev[id] ?? []).filter(g => g.seq !== seq); + const next = { ...prev }; + if (rest.length) next[id] = rest; + else delete next[id]; + return next; +}; + +export default function IssuesPage() { + const queryClient = useQueryClient(); + const [user, setUser] = useState("guest"); + const [filter, setFilter] = useState("open"); + const [selected, setSelected] = useState(null); + const seqRef = useRef(0); + const nextSeq = () => ++seqRef.current; + + // assignments in the air, and the newest one confirmed per issue (an older answer must not overwrite it) + const [assigning, setAssigning] = useState>({}); + const [assignError, setAssignError] = useState(null); + const lastAssignRef = useRef(0); + const confirmedAssignRef = useRef>({}); + + const [renaming, setRenaming] = useState>({}); + const [renameError, setRenameError] = useState(null); + const [titleInput, setTitleInput] = useState(""); + const lastRenameRef = useRef(0); + const confirmedRenameRef = useRef>({}); + + // closes in the air, by issue id, with how many are out + const [closing, setClosing] = useState>({}); + const [closeError, setCloseError] = useState(null); + const lastCloseRef = useRef(0); + + const [sending, setSending] = useState([]); + const [postError, setPostError] = useState(null); + const [commentInput, setCommentInput] = useState(""); + const lastPostRef = useRef(0); + + const list = useQuery({ queryKey: ["issues", filter], queryFn: () => fetchIssues(filter) }); + + const detail = useQuery({ + queryKey: ["issue", selected], + queryFn: async () => { + const id = selected as number; + const d = await fetchIssue(id); + if (d === null) throw new Error(`issue #${id} not found`); + return d; + }, + enabled: selected !== null, + }); + + const comments = useQuery({ + queryKey: ["comments", selected], + queryFn: () => fetchComments(selected as number), + enabled: selected !== null, + }); + + const shownIssues = list.data + ?.filter(issue => !(filter === "open" && closing[issue.id])) + .map(issue => ({ + ...issue, + title: latest(renaming, issue.id) ?? issue.title, + assignee: latest(assigning, issue.id) ?? issue.assignee, + })); + const shownDetail = detail.data && { ...detail.data, title: latest(renaming, detail.data.id) ?? detail.data.title }; + + const select = (id: number) => { + setSelected(id); + setTitleInput(""); + setAssignError(null); + setRenameError(null); + setCloseError(null); + setPostError(null); + }; + + const doAssign = async (id: number, who: string) => { + const seq = nextSeq(); + lastAssignRef.current = seq; + setAssignError(null); + setAssigning(prev => addGuess(prev, id, { seq, value: who })); + try { + const res = await assign(id, who); + if ((confirmedAssignRef.current[id] ?? 0) < seq) { + confirmedAssignRef.current[id] = seq; + queryClient.setQueriesData({ queryKey: ["issues"] }, old => + old?.map(i => (i.id === id ? { ...i, assignee: res.assignee } : i)), + ); + } + } catch (e) { + // only report the failure if no later assignment has started since + if (lastAssignRef.current === seq) setAssignError({ issueId: id, message: errorMessage(e) }); + } finally { + setAssigning(prev => dropGuess(prev, id, seq)); + } + }; + + const doRename = async (id: number, title: string) => { + const seq = nextSeq(); + lastRenameRef.current = seq; + setRenameError(null); + setRenaming(prev => addGuess(prev, id, { seq, value: title })); + try { + const res = await renameIssue(id, title); + if ((confirmedRenameRef.current[id] ?? 0) < seq) { + confirmedRenameRef.current[id] = seq; + queryClient.setQueriesData({ queryKey: ["issues"] }, old => + old?.map(i => (i.id === id ? { ...i, title: res.title } : i)), + ); + queryClient.setQueryData(["issue", id], old => (old ? { ...old, title: res.title } : old)); + } + } catch (e) { + if (lastRenameRef.current === seq) setRenameError({ issueId: id, message: errorMessage(e) }); + } finally { + setRenaming(prev => dropGuess(prev, id, seq)); + } + }; + + const doClose = async (id: number) => { + const seq = nextSeq(); + lastCloseRef.current = seq; + setCloseError(null); + setClosing(prev => ({ ...prev, [id]: (prev[id] ?? 0) + 1 })); + try { + await closeIssue(id); + queryClient.setQueryData(["issues", "open"], old => old?.filter(i => i.id !== id)); + } catch (e) { + if (lastCloseRef.current === seq) setCloseError({ issueId: id, message: errorMessage(e) }); + } finally { + setClosing(prev => { + const next = { ...prev }; + if ((next[id] ?? 0) > 1) next[id] -= 1; + else delete next[id]; + return next; + }); + } + }; + + const doPost = async (id: number, author: string, text: string) => { + const seq = nextSeq(); + lastPostRef.current = seq; + setPostError(null); + setCommentInput(""); + setSending(prev => [...prev, { seq, issueId: id, author, text }]); + try { + const res = await addComment(id, author, text); + queryClient.setQueryData<{ author: string; text: string }[]>(["comments", id], old => (old ? [...old, res] : old)); + } catch (e) { + if (lastPostRef.current === seq) setPostError({ issueId: id, message: errorMessage(e) }); + } finally { + setSending(prev => prev.filter(p => p.seq !== seq)); + } + }; + + const errorFor = (e: IssueError | null) => (e && e.issueId === selected ? e.message : null); + + return ( +
    +
    + +
    + + +
    +
    + +
    +
    +
    + {selected === null ? ( +

    select an issue

    + ) : ( + void detail.refetch()} + onAssign={() => void doAssign(selected, user)} + assignError={errorFor(assignError)} + titleInput={titleInput} + onTitleInput={setTitleInput} + onRename={() => { + if (titleInput !== "") void doRename(selected, titleInput); + }} + renameError={errorFor(renameError)} + onClose={() => void doClose(selected)} + closeError={errorFor(closeError)} + comments={comments.data} + commentsPending={comments.isPending} + commentsError={comments.error} + sending={sending.filter(p => p.issueId === selected)} + commentInput={commentInput} + onCommentInput={setCommentInput} + onPost={() => { + if (commentInput !== "") void doPost(selected, user, commentInput); + }} + postError={errorFor(postError)} + /> + )} +
    +
    + ); +} diff --git a/eval/reference/desk/react/SettingsPage.tsx b/eval/reference/desk/react/SettingsPage.tsx new file mode 100644 index 0000000..d4ad74e --- /dev/null +++ b/eval/reference/desk/react/SettingsPage.tsx @@ -0,0 +1,76 @@ +import { useRef, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { loadSettings, saveSettings, type Density, type Settings, type Theme } from "./api"; + +export default function SettingsPage() { + const queryClient = useQueryClient(); + const settings = useQuery({ queryKey: ["settings"], queryFn: loadSettings }); + const [saveError, setSaveError] = useState(""); + // saves still in the air, oldest first; the newest one shows until it answers or a newer one is confirmed + const [saving, setSaving] = useState<{ seq: number; value: Settings }[]>([]); + const seqRef = useRef(0); + const confirmedRef = useRef(0); + + const save = async (next: Settings) => { + const seq = ++seqRef.current; + setSaveError(""); + setSaving(prev => [...prev, { seq, value: next }]); + try { + const saved = await saveSettings(next); + if (seq > confirmedRef.current) { + confirmedRef.current = seq; + queryClient.setQueryData(["settings"], saved); + } + } catch (e) { + setSaveError(e instanceof Error ? e.message : String(e)); + } finally { + setSaving(prev => prev.filter(p => p.seq !== seq)); + } + }; + + if (settings.isPending) return

    Loading settings…

    ; + if (settings.error) return ( +
    +

    Could not load settings: {settings.error.message}

    {" "} + +
    + ); + const guess = saving.filter(p => p.seq > confirmedRef.current).at(-1); + const s = guess?.value ?? settings.data; + return ( +
    +

    Saved: {s.name} · {s.theme} · {s.density}{guess ? " (saving…)" : ""}

    {" "} + {saveError &&

    Save failed: {saveError}

    } + void save(draft)} /> +
    + ); +} + +function nameError(name: string) { + if (name.length === 0) return "Display name is required"; + if (name.length > 20) return `Display name must be at most 20 characters, not ${name.length}`; + return ""; +} + +// Seeded once from the loaded settings; after that the draft is the user's. +function SettingsForm({ initial, onSave }: { initial: Settings; onSave: (draft: Settings) => void }) { + const [name, setName] = useState(initial.name); + const [theme, setTheme] = useState(initial.theme); + const [density, setDensity] = useState(initial.density); + const error = nameError(name); + return ( +
    + {" "} + {name.length}/20{" "} + {error &&

    {error}

    } +
    + Theme: {theme}{" "} + {" "} + {" "} + {" "} +

    Preview: {theme} / {density}

    +
    + +
    + ); +} diff --git a/eval/reference/desk/react/errors.ts b/eval/reference/desk/react/errors.ts new file mode 100644 index 0000000..c380616 --- /dev/null +++ b/eval/reference/desk/react/errors.ts @@ -0,0 +1,4 @@ +export const errorMessage = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +/** an error that belongs to one issue: shown only while that issue is selected */ +export type IssueError = { issueId: number; message: string }; diff --git a/eval/reference/desk/rx/comments.rx b/eval/reference/desk/rx/comments.rx new file mode 100644 index 0000000..3e9b7b2 --- /dev/null +++ b/eval/reference/desk/rx/comments.rx @@ -0,0 +1,55 @@ +// The selected issue's comments, and posting one. Posts in flight live in their own source, so changing the +// selection never waits for them, and each shows only under its own issue. + +import pure { fetchComments } from "./api" +import { addComment } from "./api" + +export component Comments(props: { id: number; user: string }) { + source comments = await fetchComments(props.id) + source sending: { id: number; author: string; text: string }[] = [] + shownSending = sending.filter(s => s.id == props.id) + source postError: string | null = null + source commentInput: string := "" + + // a post error belongs to the issue it was made on: selecting another clears it + effect (props.id) { postError := null } + + action postComment(id: number, author: string, text: string) { + optimistic postError := null + try { + optimistic sending := [...sending, { id: id, author: author, text: text }] + await addComment(id, author, text) + refresh comments + } catch (e) { + postError := e.message + } + } + +
    +
    + {loading on(props.id) fallback {

    loading comments

    } { + {errored fallback(e) {

    comments error: {e.message}

    } { +
      + {for c in comments { +
    • {c.author}: {c.text}
    • + }} + {for s in shownSending { +
    • {s.author}: {s.text} (sending…)
    • + }} +
    + }} + }} +
    + { commentInput := e.target.value }} /> + + {if postError != null { +

    post error: {postError}

    + }} +
    +} diff --git a/eval/reference/desk/rx/help.rx b/eval/reference/desk/rx/help.rx new file mode 100644 index 0000000..7217eef --- /dev/null +++ b/eval/reference/desk/rx/help.rx @@ -0,0 +1,52 @@ +// The help page: search the help catalogue and open one result. + +import pure { search, fetchItem } from "./api" + +export error TooShort(q: string) => `too short: ${q}` +export error ItemNotFound(id: number) => `item ${id} not found` + +fn foundItem(it, id: number) => it == null ? throw ItemNotFound(id) : it + +export component HelpPage() { + source query: string := "" + source selected: number | null := null + results = query == "" ? [] : query.length < 2 ? throw TooShort(query) : await search(query) + item = selected == null ? null : foundItem(await fetchItem(selected), selected) + +
    + { query := e.target.value }} /> + {if query != "" { +
    + {errored fallback(e) {

    search failed: {e.message}

    } { + errored fallback(e: TooShort) {

    {e.message}

    } { + loading fallback {

    searching…

    } { +

    {pending? results ? "searching…" : ""}

    +

    {results.length} results for {latest query}

    +
      + {for r in results { +
    • { selected := r.id }}>{r.id == selected ? "> " : ""}{r.name}
    • + }} +
    + } + } + }} +
    + }} + {if selected == null { +

    pick a result

    + } else { +
    + {loading on(selected) fallback {

    loading…

    } { + {for id in [selected] { + {errored fallback(e) {

    failed: {e.message}

    } { + {errored fallback(e: ItemNotFound) {

    {e.message}

    } { +

    {item.name}

    +

    {item.blurb}

    + }} + }} + }} + }} +
    + }} +
    +} diff --git a/eval/reference/desk/rx/issues.rx b/eval/reference/desk/rx/issues.rx new file mode 100644 index 0000000..35ab63f --- /dev/null +++ b/eval/reference/desk/rx/issues.rx @@ -0,0 +1,121 @@ +// The issues page: the filtered list, the selected issue's detail, and the writes on it (assign, rename, close). + +import pure { fetchIssues, fetchIssue } from "./api" +import { assign, renameIssue, closeIssue } from "./api" +import { Comments } from "./comments.rx" + +export error NotFound(id: number) => `issue #${id} not found` + +fn found(it, id: number) => it == null ? throw NotFound(id) : it + +export component IssuesPage() { + source user: string := "guest" + source filter: string := "open" + source selected: number | null := null + source issues key id = await fetchIssues(filter) + source detail = selected == null ? null : found(await fetchIssue(selected), selected) + source titleInput: string := "" + source assignError: string = "" + source renameError: string = "" + source closeError: string = "" + + action assignTo(id: number, who: string) { + optimistic assignError := "" + try { + let i = issues.findIndex(t => t.id == id) + optimistic issues[i].assignee := who + await assign(id, who) + refresh issues + } catch (e) { + assignError := e.message + } + } + + action renameTo(id: number, title: string) { + optimistic renameError := "" + try { + let i = issues.findIndex(t => t.id == id) + if (i >= 0) { + optimistic issues[i].title := title + } + if (detail != null && detail.id == id) { + optimistic detail.title := title + } + await renameIssue(id, title) + refresh issues + refresh detail + } catch (e) { + renameError := e.message + } + } + + action closeOne(id: number) { + optimistic closeError := "" + try { + optimistic issues := issues.filter(t => t.id != id) + await closeIssue(id) + refresh issues + } catch (e) { + closeError := e.message + } + } + +
    +
    + +
    +
    + + +
    +
    + {loading on(filter) fallback {

    loading {latest filter} issues

    } { + {errored fallback(e) {

    list error: {e.message}

    } { +
      + {for it in issues { +
    • { + selected := it.id + titleInput := "" + onFlush(() => { + assignError := "" + renameError := "" + closeError := "" + }) + }}>#{it.id} {it.title} ({it.assignee ?? "unassigned"})
    • + }} +
    + }} + }} +
    +
    + {if selected != null { + {loading on(selected) fallback {

    loading #{latest selected}

    } { + {errored fallback(e) {

    detail error: {e.message}

    } { +

    #{detail.id} {detail.title}

    +

    {detail.body}

    + }} + + }} + + {if assignError != "" { +

    {assignError}

    + }} + { titleInput := e.target.value }} /> + + {if renameError != "" { +

    rename error: {renameError}

    + }} + + {if closeError != "" { +

    close error: {closeError}

    + }} + + } else { +

    select an issue

    + }} +
    +
    +} diff --git a/eval/reference/desk/rx/main.rx b/eval/reference/desk/rx/main.rx new file mode 100644 index 0000000..ffe006c --- /dev/null +++ b/eval/reference/desk/rx/main.rx @@ -0,0 +1,26 @@ +// The desk: three pages behind tabs. Only the open page is mounted, so leaving one discards its state. + +import { IssuesPage } from "./issues.rx" +import { HelpPage } from "./help.rx" +import { SettingsPage } from "./settings.rx" + +export default component App() { + source page: string := "issues" + +
    + + {if page == "issues" { + + } else { + {if page == "help" { + + } else { + + }} + }} +
    +} diff --git a/eval/reference/desk/rx/settings.rx b/eval/reference/desk/rx/settings.rx new file mode 100644 index 0000000..0df8231 --- /dev/null +++ b/eval/reference/desk/rx/settings.rx @@ -0,0 +1,61 @@ +// The settings page: the saved settings, a draft form seeded from them, and saving. + +import pure { loadSettings } from "./api" +import { saveSettings, Settings } from "./api" + +export error NameRequired() => "Display name is required" +export error NameTooLong(count: number) => `Display name must be at most 20 characters, not ${count}` + +fn validName(name: string): string => + name.length == 0 ? throw NameRequired() + : name.length > 20 ? throw NameTooLong(name.length) + : name + +export component SettingsPage() { + source settings: Settings = await loadSettings() + source saveError: string = "" + + action save(next: Settings) { + optimistic settings := next + optimistic saveError := "" + affects settings + try { + let saved = await saveSettings(next) + settings := saved + } catch (e) { + saveError := e.message + } + } + + errored fallback(e) { +

    Could not load settings: {e.message}

    + + } { + loading fallback {

    Loading settings…

    } { + source draftName: string := peek(settings.name) + source draftTheme: string := peek(settings.theme) + source draftDensity: string := peek(settings.density) + nameError: string = (validName(draftName) && "") catch (e: NameRequired | NameTooLong) => e.message +

    Saved: {settings.name} · {settings.theme} · {settings.density}{pending? settings ? " (saving…)" : ""}

    + {if saveError != "" { +

    Save failed: {saveError}

    + }} + {" "} + {draftName.length}/20 + {if nameError != "" { +

    {nameError}

    + }} +
    + {" "} + {" "} + +
    +

    Preview: {draftTheme} / {draftDensity}

    + + } + } +} diff --git a/eval/reference/desk/solid/App.tsx b/eval/reference/desk/solid/App.tsx new file mode 100644 index 0000000..09ad8a8 --- /dev/null +++ b/eval/reference/desk/solid/App.tsx @@ -0,0 +1,25 @@ +import { createSignal, Match, Switch } from "solid-js"; +import IssuesPage from "./IssuesPage"; +import HelpPage from "./HelpPage"; +import SettingsPage from "./SettingsPage"; + +type Page = "issues" | "help" | "settings"; + +// Only the open page is mounted, so leaving one discards its state. +export default function App() { + const [page, setPage] = createSignal("issues"); + return ( +
    + + + + + + +
    + ); +} diff --git a/eval/reference/desk/solid/Comments.tsx b/eval/reference/desk/solid/Comments.tsx new file mode 100644 index 0000000..1518ac3 --- /dev/null +++ b/eval/reference/desk/solid/Comments.tsx @@ -0,0 +1,65 @@ +import { action, createEffect, createMemo, createOptimisticStore, createSignal, Errored, For, Loading, onFlush, refresh, Show } from "solid-js"; +import { addComment, fetchComments } from "./api"; + +const message = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +type Sending = { id: number; author: string; text: string }; + +// The selected issue's comments, and posting one. Posts in flight live in their own store, so changing the selection +// never waits for them, and each shows only under its own issue. +export default function Comments(props: { id: number; user: string }) { + const comments = createMemo(() => fetchComments(props.id)); + const [sending, setSending] = createOptimisticStore([]); + const [postError, setPostError] = createSignal(""); + const [commentInput, setCommentInput] = createSignal(""); + + // a post error belongs to the issue it was made on: selecting another clears it + createEffect(() => props.id, () => { + setPostError(""); + }); + + const post = action(function* (id: number, author: string, text: string) { + try { + setSending(s => { s.push({ id, author, text }); }); + yield addComment(id, author, text); + refresh(comments); + } catch (e) { + setPostError(message(e)); + } + }); + + return ( +
    +
    + loading comments

    }> +

    comments error: {message(e())}

    }> +
      + {c =>
    • {c.author}: {c.text}
    • }
      + s.id === props.id)}> + {s =>
    • {s.author}: {s.text} (sending…)
    • } +
      +
    +
    +
    +
    + setCommentInput(e.currentTarget.value)} /> + + +

    post error: {postError()}

    +
    +
    + ); +} diff --git a/eval/reference/desk/solid/HelpPage.tsx b/eval/reference/desk/solid/HelpPage.tsx new file mode 100644 index 0000000..40226a6 --- /dev/null +++ b/eval/reference/desk/solid/HelpPage.tsx @@ -0,0 +1,82 @@ +import { createMemo, createSignal, Errored, For, isPending, latest, Loading, refresh, Show } from "solid-js"; +import { fetchItem, search } from "./api"; + +const message = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +class TooShort extends Error {} +class ItemNotFound extends Error {} + +export default function HelpPage() { + const [query, setQuery] = createSignal(""); + const [selected, setSelected] = createSignal(null); + + // empty: nothing; one character: too short, nothing asked; otherwise the search + const results = createMemo(() => { + const q = query(); + if (q === "") return []; + if (q.length < 2) throw new TooShort(`too short: ${q}`); + return search(q); + }); + const item = createMemo(async () => { + const id = selected(); + if (id === null) return null; + const it = await fetchItem(id); + if (it === null) throw new ItemNotFound(`item ${id} not found`); + return it; + }); + + return ( +
    + {/* the input shows what was typed even while the search it started is in flight */} + setQuery(e.currentTarget.value)} /> + +
    + (e() instanceof TooShort ?

    {message(e())}

    :

    search failed: {message(e())}

    )}> + searching…

    }> +

    {isPending(() => results()) ? "searching…" : ""}

    +

    + {results().length} results for {latest(query)} +

    +
      + + {r => ( +
    • setSelected(r.id)}> + {r.id === selected() ? "> " : ""} + {r.name} +
    • + )} +
      +
    +
    +
    +
    +
    + pick a result

    }> +
    + loading…

    }> + {/* a fresh region per selection, so a failure shown for one item doesn't stay up while the next loads */} + + {_id => ( + + e() instanceof ItemNotFound ? ( +

    {message(e())}

    + ) : ( + <> +

    failed: {message(e())}

    + + + ) + } + > +

    {item()?.name}

    +

    {item()?.blurb}

    +
    + )} +
    +
    +
    +
    +
    + ); +} diff --git a/eval/reference/desk/solid/IssuesPage.tsx b/eval/reference/desk/solid/IssuesPage.tsx new file mode 100644 index 0000000..d529f7d --- /dev/null +++ b/eval/reference/desk/solid/IssuesPage.tsx @@ -0,0 +1,155 @@ +import { action, createOptimistic, createOptimisticStore, createSignal, Errored, For, latest, Loading, onFlush, refresh, Show } from "solid-js"; +import { assign, closeIssue, fetchIssue, fetchIssues, renameIssue, type Issue, type IssueDetail } from "./api"; +import Comments from "./Comments"; + +const message = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +export default function IssuesPage() { + const [user, setUser] = createSignal("guest"); + const [filter, setFilter] = createSignal<"open" | "closed">("open"); + const [selected, setSelected] = createSignal(null); + const [titleInput, setTitleInput] = createSignal(""); + const [assignError, setAssignError] = createSignal(""); + const [renameError, setRenameError] = createSignal(""); + const [closeError, setCloseError] = createSignal(""); + + // the list for the filter; writes in an action are guesses until it settles + const [issues, setIssues] = createOptimisticStore(() => fetchIssues(filter()), [], { key: "id" }); + const [detail, setDetail] = createOptimistic(async () => { + const id = selected(); + if (id === null) return null; + const d = await fetchIssue(id); + if (d === null) throw new Error(`issue #${id} not found`); + return d; + }); + + const assignTo = action(function* (id: number, who: string) { + try { + setIssues(list => { + const row = list.find(t => t.id === id); + if (row) row.assignee = who; + }); + yield assign(id, who); + refresh(issues); + } catch (e) { + setAssignError(message(e)); + } + }); + + const renameTo = action(function* (id: number, title: string) { + try { + setIssues(list => { + const row = list.find(t => t.id === id); + if (row) row.title = title; + }); + setDetail(d => (d && d.id === id ? { ...d, title } : d)); + yield renameIssue(id, title); + refresh(issues); + refresh(detail); + } catch (e) { + setRenameError(message(e)); + } + }); + + const closeOne = action(function* (id: number) { + try { + setIssues(list => { + const i = list.findIndex(t => t.id === id); + if (i >= 0) list.splice(i, 1); + }); + yield closeIssue(id); + refresh(issues); + } catch (e) { + setCloseError(message(e)); + } + }); + + const select = (id: number) => { + setSelected(id); + setTitleInput(""); + onFlush(() => { + setAssignError(""); + setRenameError(""); + setCloseError(""); + }); + }; + + return ( +
    +
    + +
    +
    + + +
    +
    + loading {latest(filter)} issues

    }> +

    list error: {message(e())}

    }> +
      + + {it => ( +
    • select(it.id)}> + #{it.id} {it.title} ({it.assignee ?? "unassigned"}) +
    • + )} +
      +
    +
    +
    +
    +
    + select an issue

    }> + loading #{latest(selected)}

    }> +

    detail error: {message(e())}

    }> +

    #{detail()?.id} {detail()?.title}

    +

    {detail()?.body}

    +
    + +
    + + +

    {assignError()}

    +
    + setTitleInput(e.currentTarget.value)} /> + + +

    rename error: {renameError()}

    +
    + + +

    close error: {closeError()}

    +
    + +
    +
    +
    + ); +} diff --git a/eval/reference/desk/solid/SettingsPage.tsx b/eval/reference/desk/solid/SettingsPage.tsx new file mode 100644 index 0000000..5e949d3 --- /dev/null +++ b/eval/reference/desk/solid/SettingsPage.tsx @@ -0,0 +1,92 @@ +import { action, affects, createOptimistic, createSignal, Errored, isPending, Loading, onFlush, refresh, Show, untrack } from "solid-js"; +import { loadSettings, saveSettings, type Density, type Settings, type Theme } from "./api"; + +const message = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +function nameError(name: string) { + if (name.length === 0) return "Display name is required"; + if (name.length > 20) return `Display name must be at most 20 characters, not ${name.length}`; + return ""; +} + +export default function SettingsPage() { + // what the server has; a save shows its settings over it as a guess until it settles + const [saved, setSaved] = createSignal(() => loadSettings()); + const [settings, setSettings] = createOptimistic(() => saved()); + const [saveError, setSaveError] = createSignal(""); + + const save = action(function* (next: Settings) { + setSettings(next); + affects(settings); + try { + setSaved(yield saveSettings(next)); + } catch (e) { + setSaveError(message(e)); + } + }); + + return ( + ( + <> +

    Could not load settings: {message(e())}

    + + + )} + > + Loading settings…

    }> +

    + Saved: {settings().name} · {settings().theme} · {settings().density} + {isPending(() => settings()) ? " (saving…)" : ""} +

    + +

    Save failed: {saveError()}

    +
    + { + onFlush(() => setSaveError("")); + save(s); + }} + /> +
    +
    + ); +} + +// Seeded once from the loaded settings; after that the draft is the user's. +function SettingsForm(props: { initial: Settings; onSave: (s: Settings) => void }) { + const [name, setName] = createSignal(untrack(() => props.initial.name)); + const [theme, setTheme] = createSignal(untrack(() => props.initial.theme)); + const [density, setDensity] = createSignal(untrack(() => props.initial.density)); + const error = () => nameError(name()); + return ( +
    + {" "} + {name().length}/20 + +

    {error()}

    +
    +
    + {" "} + {" "} + +
    +

    + Preview: {theme()} / {density()} +

    + +
    + ); +} diff --git a/eval/reference/labels/foldkit/help.ts b/eval/reference/labels/foldkit/help.ts new file mode 100644 index 0000000..b9f086d --- /dev/null +++ b/eval/reference/labels/foldkit/help.ts @@ -0,0 +1,213 @@ +import { Array, Effect, Option, Schema } from 'effect' +import { Command, Submodel, type Update } from 'foldkit' +import { Html, HtmlBuilder } from 'foldkit/html' +import { defineMessageUnion } from 'foldkit/message' +import { defineTaggedUnion } from 'foldkit/schema' +import { modifyFields } from 'foldkit/struct' + +import * as Api from './api' +import { request } from './request' + +// MODEL + +const Hit = Schema.Struct({ id: Schema.Number, name: Schema.String }) +const Item = Schema.Struct({ id: Schema.Number, name: Schema.String, blurb: Schema.String }) + +// The results on screen stay until a newer query's answer replaces them. +const Shown = Schema.Struct({ query: Schema.String, hits: Schema.Array(Hit) }) + +export const ItemState = defineTaggedUnion({ + Loading: {}, + Loaded: { maybeItem: Schema.Option(Item) }, + Failed: { error: Schema.String }, +}) + +export const Model = Schema.Struct({ + query: Schema.String, + searchSeq: Schema.Number, + isSearching: Schema.Boolean, + maybeShown: Schema.Option(Shown), + maybeSearchError: Schema.Option(Schema.String), + maybeSelected: Schema.Option(Schema.Number), + itemSeq: Schema.Number, + item: ItemState, +}) +export type Model = typeof Model.Type + +// MESSAGE + +export const Message = defineMessageUnion({ + UpdatedQuery: { value: Schema.String }, + SucceededSearch: { seq: Schema.Number, query: Schema.String, hits: Schema.Array(Hit) }, + FailedSearch: { seq: Schema.Number, error: Schema.String }, + ClickedHit: { id: Schema.Number }, + SucceededFetchItem: { seq: Schema.Number, item: Schema.NullOr(Item) }, + FailedFetchItem: { seq: Schema.Number, error: Schema.String }, + ClickedRetryItem: {}, +}) +export type Message = typeof Message.Type + +// COMMAND + +const Search = Command.define('Search', { + args: { seq: Schema.Number, query: Schema.String }, + messages: [Message.SucceededSearch, Message.FailedSearch], + execute: ({ seq, query }) => + request(() => Api.search(query)).pipe( + Effect.map(hits => Message.SucceededSearch({ seq, query, hits })), + Effect.catch(error => Effect.succeed(Message.FailedSearch({ seq, error }))), + ), +}) + +const FetchItem = Command.define('FetchItem', { + args: { seq: Schema.Number, id: Schema.Number }, + messages: [Message.SucceededFetchItem, Message.FailedFetchItem], + execute: ({ seq, id }) => + request(() => Api.fetchItem(id)).pipe( + Effect.map(item => Message.SucceededFetchItem({ seq, item })), + Effect.catch(error => Effect.succeed(Message.FailedFetchItem({ seq, error }))), + ), +}) + +// INIT + +export const init = (): Update.Return => ({ + model: { + query: '', + searchSeq: 0, + isSearching: false, + maybeShown: Option.none(), + maybeSearchError: Option.none(), + maybeSelected: Option.none(), + itemSeq: 0, + item: ItemState.Loading(), + }, +}) + +// UPDATE + +export const update = (model: Model, message: Message) => + Message.match>(message, { + UpdatedQuery: ({ value }) => { + const searchSeq = model.searchSeq + 1 + const isAsked = value.length >= 2 + const next = modifyFields(model, { + query: () => value, + searchSeq: () => searchSeq, + isSearching: () => isAsked, + maybeSearchError: () => Option.none(), + maybeShown: shown => (value === '' ? Option.none() : shown), + }) + return isAsked ? { model: next, commands: [Search({ seq: searchSeq, query: value })] } : { model: next } + }, + + SucceededSearch: ({ seq, query, hits }) => + seq === model.searchSeq + ? { + model: modifyFields(model, { + isSearching: () => false, + maybeShown: () => Option.some({ query, hits }), + }), + } + : { model }, + + FailedSearch: ({ seq, error }) => + seq === model.searchSeq + ? { model: modifyFields(model, { isSearching: () => false, maybeSearchError: () => Option.some(error) }) } + : { model }, + + ClickedHit: ({ id }) => { + if (Option.contains(model.maybeSelected, id)) { + return { model } + } + const itemSeq = model.itemSeq + 1 + return { + model: modifyFields(model, { + maybeSelected: () => Option.some(id), + itemSeq: () => itemSeq, + item: () => ItemState.Loading(), + }), + commands: [FetchItem({ seq: itemSeq, id })], + } + }, + + ClickedRetryItem: () => + Option.match(model.maybeSelected, { + onNone: () => ({ model }), + onSome: id => { + const itemSeq = model.itemSeq + 1 + return { model: modifyFields(model, { itemSeq: () => itemSeq }), commands: [FetchItem({ seq: itemSeq, id })] } + }, + }), + + SucceededFetchItem: ({ seq, item }) => + seq === model.itemSeq + ? { model: modifyFields(model, { item: () => ItemState.Loaded({ maybeItem: Option.fromNullishOr(item) }) }) } + : { model }, + + FailedFetchItem: ({ seq, error }) => + seq === model.itemSeq ? { model: modifyFields(model, { item: () => ItemState.Failed({ error }) }) } : { model }, + }) + +// VIEW + +const testId = (h: HtmlBuilder, id: string) => h.DataAttribute('testid', id) + +const resultsView = (model: Model, h: HtmlBuilder): Html => { + if (model.query.length < 2) { + return h.section([testId(h, 'results')], [h.p([], [`too short: ${model.query}`])]) + } + const shown = Option.match(model.maybeSearchError, { + onSome: error => (model.isSearching ? h.empty : h.p([], [`search failed: ${error}`])), + onNone: () => + Option.match(model.maybeShown, { + onNone: () => h.empty, + onSome: ({ query, hits }) => + h.div([], [ + h.h2([], [`${hits.length} results for ${query}`]), + h.ul( + [], + Array.map(hits, hit => + h.keyed('li')( + String(hit.id), + [testId(h, 'hit'), h.OnClick(Message.ClickedHit({ id: hit.id }))], + [`${Option.contains(model.maybeSelected, hit.id) ? '> ' : ''}${hit.name}`], + ), + ), + ), + ]), + }), + }) + return h.section([testId(h, 'results')], [model.isSearching ? h.p([], ['searching…']) : h.empty, shown]) +} + +const itemView = (model: Model, id: number, h: HtmlBuilder): Html => + h.section( + [testId(h, 'item')], + [ + ItemState.match(model.item, { + Loading: () => h.p([], ['loading…']), + Failed: ({ error }) => + h.div([], [ + h.p([], [`failed: ${error}`]), + h.button([testId(h, 'retry-item'), h.OnClick(Message.ClickedRetryItem())], ['retry']), + ]), + Loaded: ({ maybeItem }) => + Option.match(maybeItem, { + onNone: () => h.p([], [`item ${id} not found`]), + onSome: item => h.article([], [h.h3([], [item.name]), h.p([], [item.blurb])]), + }), + }), + ], + ) + +export const view = Submodel.defineView((model, h) => + h.main([], [ + h.input([testId(h, 'query'), h.Value(model.query), h.OnInput(value => Message.UpdatedQuery({ value }))]), + model.query === '' ? h.empty : resultsView(model, h), + Option.match(model.maybeSelected, { + onNone: () => h.p([testId(h, 'item')], ['pick a result']), + onSome: id => itemView(model, id, h), + }), + ]), +) diff --git a/eval/reference/labels/foldkit/issues.ts b/eval/reference/labels/foldkit/issues.ts new file mode 100644 index 0000000..1d94eca --- /dev/null +++ b/eval/reference/labels/foldkit/issues.ts @@ -0,0 +1,751 @@ +import { Array, Effect, Option, Schema, pipe } from 'effect' +import { Command, Submodel, type Update } from 'foldkit' +import { Html, HtmlBuilder } from 'foldkit/html' +import { defineMessageUnion } from 'foldkit/message' +import { defineTaggedUnion } from 'foldkit/schema' +import { modifyFields } from 'foldkit/struct' + +import * as Api from './api' +import { request } from './request' + +// MODEL + +const Filter = Schema.Literals(['open', 'closed']) +type Filter = typeof Filter.Type + +const Issue = Schema.Struct({ id: Schema.Number, title: Schema.String, assignee: Schema.NullOr(Schema.String) }) +type Issue = typeof Issue.Type +const Detail = Schema.Struct({ id: Schema.Number, title: Schema.String, body: Schema.String }) +const Comment = Schema.Struct({ author: Schema.String, text: Schema.String }) + +export const ListState = defineTaggedUnion({ + Loading: {}, + Loaded: { issues: Schema.Array(Issue) }, + Failed: { error: Schema.String }, +}) +export const DetailState = defineTaggedUnion({ + Loading: {}, + Loaded: { detail: Detail }, + Failed: { error: Schema.String }, +}) +export const CommentsState = defineTaggedUnion({ + Loading: {}, + Loaded: { comments: Schema.Array(Comment) }, + Failed: { error: Schema.String }, +}) + +export const LabelsState = defineTaggedUnion({ + Loading: {}, + Loaded: { labels: Schema.Array(Schema.String) }, + Failed: { error: Schema.String }, +}) + +// A label change in the air, on one issue. +const LabelChange = Schema.Struct({ seq: Schema.Number, id: Schema.Number, label: Schema.String }) + +// A write still in the air. `seq` orders writes: the newest guess for an issue is what it shows. +const Guess = Schema.Struct({ seq: Schema.Number, id: Schema.Number, value: Schema.String }) +type Guess = typeof Guess.Type +const Sending = Schema.Struct({ seq: Schema.Number, id: Schema.Number, author: Schema.String, text: Schema.String }) +// An error that belongs to one issue: shown only while that issue is selected. +const IssueError = Schema.Struct({ id: Schema.Number, message: Schema.String }) +type IssueError = typeof IssueError.Type +// The newest write of a kind that the server confirmed, per issue. +const Confirmed = Schema.Struct({ id: Schema.Number, seq: Schema.Number }) +type Confirmed = typeof Confirmed.Type + +export const Model = Schema.Struct({ + user: Schema.String, + filter: Filter, + listSeq: Schema.Number, + list: ListState, + maybeSelected: Schema.Option(Schema.Number), + detailSeq: Schema.Number, + detail: DetailState, + comments: CommentsState, + seq: Schema.Number, + assigning: Schema.Array(Guess), + confirmedAssigns: Schema.Array(Confirmed), + lastAssign: Schema.Number, + maybeAssignError: Schema.Option(IssueError), + titleInput: Schema.String, + renaming: Schema.Array(Guess), + confirmedRenames: Schema.Array(Confirmed), + lastRename: Schema.Number, + maybeRenameError: Schema.Option(IssueError), + closing: Schema.Array(Schema.Struct({ seq: Schema.Number, id: Schema.Number })), + lastClose: Schema.Number, + maybeCloseError: Schema.Option(IssueError), + commentInput: Schema.String, + sending: Schema.Array(Sending), + lastPost: Schema.Number, + maybePostError: Schema.Option(IssueError), + labels: LabelsState, + labelInput: Schema.String, + adding: Schema.Array(LabelChange), + removing: Schema.Array(LabelChange), + lastLabel: Schema.Number, + labelError: Schema.String, +}) +export type Model = typeof Model.Type + +// MESSAGE + +export const Message = defineMessageUnion({ + UpdatedUser: { value: Schema.String }, + ClickedFilter: { filter: Filter }, + SucceededFetchIssues: { seq: Schema.Number, issues: Schema.Array(Issue) }, + FailedFetchIssues: { seq: Schema.Number, error: Schema.String }, + ClickedIssue: { id: Schema.Number }, + ClickedRetry: {}, + SucceededFetchIssue: { seq: Schema.Number, detail: Schema.NullOr(Detail) }, + FailedFetchIssue: { seq: Schema.Number, error: Schema.String }, + SucceededFetchComments: { seq: Schema.Number, comments: Schema.Array(Comment) }, + FailedFetchComments: { seq: Schema.Number, error: Schema.String }, + ClickedAssign: {}, + SucceededAssign: { seq: Schema.Number, id: Schema.Number, assignee: Schema.String }, + FailedAssign: { seq: Schema.Number, id: Schema.Number, error: Schema.String }, + UpdatedTitleInput: { value: Schema.String }, + ClickedRename: {}, + SucceededRename: { seq: Schema.Number, id: Schema.Number, title: Schema.String }, + FailedRename: { seq: Schema.Number, id: Schema.Number, error: Schema.String }, + ClickedClose: {}, + SucceededClose: { seq: Schema.Number, id: Schema.Number }, + FailedClose: { seq: Schema.Number, id: Schema.Number, error: Schema.String }, + UpdatedCommentInput: { value: Schema.String }, + ClickedPost: {}, + SucceededPost: { seq: Schema.Number, id: Schema.Number, comment: Comment }, + FailedPost: { seq: Schema.Number, id: Schema.Number, error: Schema.String }, + SucceededFetchLabels: { seq: Schema.Number, labels: Schema.Array(Schema.String) }, + FailedFetchLabels: { seq: Schema.Number, error: Schema.String }, + UpdatedLabelInput: { value: Schema.String }, + ClickedAddLabel: {}, + ClickedRemoveLabel: { label: Schema.String }, + SucceededChangeLabel: { seq: Schema.Number, id: Schema.Number, labels: Schema.Array(Schema.String) }, + FailedChangeLabel: { seq: Schema.Number, id: Schema.Number, error: Schema.String }, +}) +export type Message = typeof Message.Type + +// COMMAND + +const FetchIssues = Command.define('FetchIssues', { + args: { seq: Schema.Number, filter: Filter }, + messages: [Message.SucceededFetchIssues, Message.FailedFetchIssues], + execute: ({ seq, filter }) => + request(() => Api.fetchIssues(filter)).pipe( + Effect.map(issues => Message.SucceededFetchIssues({ seq, issues })), + Effect.catch(error => Effect.succeed(Message.FailedFetchIssues({ seq, error }))), + ), +}) + +const FetchIssue = Command.define('FetchIssue', { + args: { seq: Schema.Number, id: Schema.Number }, + messages: [Message.SucceededFetchIssue, Message.FailedFetchIssue], + execute: ({ seq, id }) => + request(() => Api.fetchIssue(id)).pipe( + Effect.map(detail => Message.SucceededFetchIssue({ seq, detail })), + Effect.catch(error => Effect.succeed(Message.FailedFetchIssue({ seq, error }))), + ), +}) + +const FetchComments = Command.define('FetchComments', { + args: { seq: Schema.Number, id: Schema.Number }, + messages: [Message.SucceededFetchComments, Message.FailedFetchComments], + execute: ({ seq, id }) => + request(() => Api.fetchComments(id)).pipe( + Effect.map(comments => Message.SucceededFetchComments({ seq, comments })), + Effect.catch(error => Effect.succeed(Message.FailedFetchComments({ seq, error }))), + ), +}) + +const Assign = Command.define('Assign', { + args: { seq: Schema.Number, id: Schema.Number, user: Schema.String }, + messages: [Message.SucceededAssign, Message.FailedAssign], + execute: ({ seq, id, user }) => + request(() => Api.assign(id, user)).pipe( + Effect.map(answer => Message.SucceededAssign({ seq, id, assignee: answer.assignee })), + Effect.catch(error => Effect.succeed(Message.FailedAssign({ seq, id, error }))), + ), +}) + +const Rename = Command.define('Rename', { + args: { seq: Schema.Number, id: Schema.Number, title: Schema.String }, + messages: [Message.SucceededRename, Message.FailedRename], + execute: ({ seq, id, title }) => + request(() => Api.renameIssue(id, title)).pipe( + Effect.map(answer => Message.SucceededRename({ seq, id, title: answer.title })), + Effect.catch(error => Effect.succeed(Message.FailedRename({ seq, id, error }))), + ), +}) + +const Close = Command.define('Close', { + args: { seq: Schema.Number, id: Schema.Number }, + messages: [Message.SucceededClose, Message.FailedClose], + execute: ({ seq, id }) => + request(() => Api.closeIssue(id)).pipe( + Effect.as(Message.SucceededClose({ seq, id })), + Effect.catch(error => Effect.succeed(Message.FailedClose({ seq, id, error }))), + ), +}) + +const Post = Command.define('Post', { + args: { seq: Schema.Number, id: Schema.Number, author: Schema.String, text: Schema.String }, + messages: [Message.SucceededPost, Message.FailedPost], + execute: ({ seq, id, author, text }) => + request(() => Api.addComment(id, author, text)).pipe( + Effect.map(comment => Message.SucceededPost({ seq, id, comment })), + Effect.catch(error => Effect.succeed(Message.FailedPost({ seq, id, error }))), + ), +}) + +const FetchLabels = Command.define('FetchLabels', { + args: { seq: Schema.Number, id: Schema.Number }, + messages: [Message.SucceededFetchLabels, Message.FailedFetchLabels], + execute: ({ seq, id }) => + request(() => Api.fetchLabels(id)).pipe( + Effect.map(labels => Message.SucceededFetchLabels({ seq, labels })), + Effect.catch(error => Effect.succeed(Message.FailedFetchLabels({ seq, error }))), + ), +}) + +const ChangeLabel = Command.define('ChangeLabel', { + args: { seq: Schema.Number, id: Schema.Number, label: Schema.String, kind: Schema.Literals(['add', 'remove']) }, + messages: [Message.SucceededChangeLabel, Message.FailedChangeLabel], + execute: ({ seq, id, label, kind }) => + request(() => (kind === 'add' ? Api.addLabel(id, label) : Api.removeLabel(id, label))).pipe( + Effect.map(labels => Message.SucceededChangeLabel({ seq, id, labels })), + Effect.catch(error => Effect.succeed(Message.FailedChangeLabel({ seq, id, error }))), + ), +}) + +// INIT + +export const init = (): Update.Return => ({ + model: { + user: 'guest', + filter: 'open', + listSeq: 1, + list: ListState.Loading(), + maybeSelected: Option.none(), + detailSeq: 0, + detail: DetailState.Loading(), + comments: CommentsState.Loading(), + seq: 1, + assigning: [], + confirmedAssigns: [], + lastAssign: 0, + maybeAssignError: Option.none(), + titleInput: '', + renaming: [], + confirmedRenames: [], + lastRename: 0, + maybeRenameError: Option.none(), + closing: [], + lastClose: 0, + maybeCloseError: Option.none(), + commentInput: '', + sending: [], + lastPost: 0, + maybePostError: Option.none(), + labels: LabelsState.Loading(), + labelInput: '', + adding: [], + removing: [], + lastLabel: 0, + labelError: '', + }, + commands: [FetchIssues({ seq: 1, filter: 'open' })], +}) + +// UPDATE + +const dropGuess = + (seq: number) => +
    (guesses: ReadonlyArray): ReadonlyArray => + Array.filter(guesses, guess => guess.seq !== seq) + +const latestGuess = (guesses: ReadonlyArray, id: number): Option.Option => + pipe( + guesses, + Array.filter(guess => guess.id === id), + Array.last, + Option.map(guess => guess.value), + ) + +const confirmedSeq = (confirmed: ReadonlyArray, id: number): number => + pipe( + confirmed, + Array.findFirst(entry => entry.id === id), + Option.match({ onNone: () => 0, onSome: entry => entry.seq }), + ) + +const confirm = (confirmed: ReadonlyArray, id: number, seq: number): ReadonlyArray => [ + ...Array.filter(confirmed, entry => entry.id !== id), + { id, seq }, +] + +const mapIssue = (id: number, f: (issue: Issue) => Issue) => (list: typeof ListState.Type) => + ListState.match(list, { + Loading: () => list, + Failed: () => list, + Loaded: ({ issues }) => ListState.Loaded({ issues: Array.map(issues, issue => (issue.id === id ? f(issue) : issue)) }), + }) + +const isSelected = (model: Model, id: number) => Option.contains(model.maybeSelected, id) + +// Only the newest write of a kind reports its failure. +const failure = (last: number, seq: number, id: number, message: string) => (current: Option.Option) => + last === seq ? Option.some({ id, message }) : current + +export const update = (model: Model, message: Message) => + Message.match>(message, { + UpdatedUser: ({ value }) => ({ model: modifyFields(model, { user: () => value }) }), + + ClickedFilter: ({ filter }) => { + if (filter === model.filter) { + return { model } + } + const listSeq = model.listSeq + 1 + return { + model: modifyFields(model, { filter: () => filter, listSeq: () => listSeq, list: () => ListState.Loading() }), + commands: [FetchIssues({ seq: listSeq, filter })], + } + }, + + SucceededFetchIssues: ({ seq, issues }) => + seq === model.listSeq ? { model: modifyFields(model, { list: () => ListState.Loaded({ issues }) }) } : { model }, + + FailedFetchIssues: ({ seq, error }) => + seq === model.listSeq ? { model: modifyFields(model, { list: () => ListState.Failed({ error }) }) } : { model }, + + ClickedIssue: ({ id }) => { + const cleared = modifyFields(model, { + titleInput: () => '', + maybeAssignError: () => Option.none(), + maybeRenameError: () => Option.none(), + maybeCloseError: () => Option.none(), + maybePostError: () => Option.none(), + }) + if (isSelected(model, id)) { + return { model: cleared } + } + const detailSeq = model.detailSeq + 1 + return { + model: modifyFields(cleared, { + maybeSelected: () => Option.some(id), + detailSeq: () => detailSeq, + detail: () => DetailState.Loading(), + comments: () => CommentsState.Loading(), + labels: () => LabelsState.Loading(), + labelError: () => '', + }), + commands: [ + FetchIssue({ seq: detailSeq, id }), + FetchComments({ seq: detailSeq, id }), + FetchLabels({ seq: detailSeq, id }), + ], + } + }, + + ClickedRetry: () => + Option.match(model.maybeSelected, { + onNone: () => ({ model }), + onSome: id => { + const detailSeq = model.detailSeq + 1 + return { + model: modifyFields(model, { detailSeq: () => detailSeq, detail: () => DetailState.Loading() }), + commands: [FetchIssue({ seq: detailSeq, id })], + } + }, + }), + + SucceededFetchIssue: ({ seq, detail }) => { + if (seq !== model.detailSeq) { + return { model } + } + const id = Option.getOrElse(model.maybeSelected, () => 0) + return { + model: modifyFields(model, { + detail: () => + detail === null ? DetailState.Failed({ error: `issue #${id} not found` }) : DetailState.Loaded({ detail }), + }), + } + }, + + FailedFetchIssue: ({ seq, error }) => + seq === model.detailSeq ? { model: modifyFields(model, { detail: () => DetailState.Failed({ error }) }) } : { model }, + + SucceededFetchComments: ({ seq, comments }) => + seq === model.detailSeq + ? { model: modifyFields(model, { comments: () => CommentsState.Loaded({ comments }) }) } + : { model }, + + FailedFetchComments: ({ seq, error }) => + seq === model.detailSeq ? { model: modifyFields(model, { comments: () => CommentsState.Failed({ error }) }) } : { model }, + + ClickedAssign: () => + Option.match(model.maybeSelected, { + onNone: () => ({ model }), + onSome: id => ({ + model: modifyFields(model, { + seq: seq => seq + 1, + lastAssign: () => model.seq, + maybeAssignError: () => Option.none(), + assigning: Array.append({ seq: model.seq, id, value: model.user }), + }), + commands: [Assign({ seq: model.seq, id, user: model.user })], + }), + }), + + SucceededAssign: ({ seq, id, assignee }) => { + const isNewest = confirmedSeq(model.confirmedAssigns, id) < seq + return { + model: modifyFields(model, { + assigning: dropGuess(seq), + confirmedAssigns: confirmed => (isNewest ? confirm(confirmed, id, seq) : confirmed), + list: list => (isNewest ? mapIssue(id, issue => ({ ...issue, assignee }))(list) : list), + }), + } + }, + + FailedAssign: ({ seq, id, error }) => ({ + model: modifyFields(model, { + assigning: dropGuess(seq), + maybeAssignError: failure(model.lastAssign, seq, id, error), + }), + }), + + UpdatedTitleInput: ({ value }) => ({ model: modifyFields(model, { titleInput: () => value }) }), + + ClickedRename: () => + Option.match(model.maybeSelected, { + onNone: () => ({ model }), + onSome: id => + model.titleInput === '' + ? { model } + : { + model: modifyFields(model, { + seq: seq => seq + 1, + lastRename: () => model.seq, + maybeRenameError: () => Option.none(), + renaming: Array.append({ seq: model.seq, id, value: model.titleInput }), + }), + commands: [Rename({ seq: model.seq, id, title: model.titleInput })], + }, + }), + + SucceededRename: ({ seq, id, title }) => { + const isNewest = confirmedSeq(model.confirmedRenames, id) < seq + return { + model: modifyFields(model, { + renaming: dropGuess(seq), + confirmedRenames: confirmed => (isNewest ? confirm(confirmed, id, seq) : confirmed), + list: list => (isNewest ? mapIssue(id, issue => ({ ...issue, title }))(list) : list), + detail: detail => + isNewest && detail._tag === 'Loaded' && detail.detail.id === id + ? DetailState.Loaded({ detail: { ...detail.detail, title } }) + : detail, + }), + } + }, + + FailedRename: ({ seq, id, error }) => ({ + model: modifyFields(model, { + renaming: dropGuess(seq), + maybeRenameError: failure(model.lastRename, seq, id, error), + }), + }), + + ClickedClose: () => + Option.match(model.maybeSelected, { + onNone: () => ({ model }), + onSome: id => ({ + model: modifyFields(model, { + seq: seq => seq + 1, + lastClose: () => model.seq, + maybeCloseError: () => Option.none(), + closing: Array.append({ seq: model.seq, id }), + }), + commands: [Close({ seq: model.seq, id })], + }), + }), + + SucceededClose: ({ seq, id }) => ({ + model: modifyFields(model, { + closing: dropGuess(seq), + list: list => + model.filter === 'open' && list._tag === 'Loaded' + ? ListState.Loaded({ issues: Array.filter(list.issues, issue => issue.id !== id) }) + : list, + }), + }), + + FailedClose: ({ seq, id, error }) => ({ + model: modifyFields(model, { + closing: dropGuess(seq), + maybeCloseError: failure(model.lastClose, seq, id, error), + }), + }), + + UpdatedCommentInput: ({ value }) => ({ model: modifyFields(model, { commentInput: () => value }) }), + + ClickedPost: () => + Option.match(model.maybeSelected, { + onNone: () => ({ model }), + onSome: id => + model.commentInput === '' + ? { model } + : { + model: modifyFields(model, { + seq: seq => seq + 1, + lastPost: () => model.seq, + maybePostError: () => Option.none(), + commentInput: () => '', + sending: Array.append({ seq: model.seq, id, author: model.user, text: model.commentInput }), + }), + commands: [Post({ seq: model.seq, id, author: model.user, text: model.commentInput })], + }, + }), + + SucceededPost: ({ seq, id, comment }) => ({ + model: modifyFields(model, { + sending: dropGuess(seq), + comments: comments => + isSelected(model, id) && comments._tag === 'Loaded' + ? CommentsState.Loaded({ comments: Array.append(comments.comments, comment) }) + : comments, + }), + }), + + FailedPost: ({ seq, id, error }) => ({ + model: modifyFields(model, { + sending: dropGuess(seq), + maybePostError: failure(model.lastPost, seq, id, error), + }), + }), + + SucceededFetchLabels: ({ seq, labels }) => + seq === model.detailSeq ? { model: modifyFields(model, { labels: () => LabelsState.Loaded({ labels }) }) } : { model }, + + FailedFetchLabels: ({ seq, error }) => + seq === model.detailSeq ? { model: modifyFields(model, { labels: () => LabelsState.Failed({ error }) }) } : { model }, + + UpdatedLabelInput: ({ value }) => ({ model: modifyFields(model, { labelInput: () => value }) }), + + ClickedAddLabel: () => + Option.match(model.maybeSelected, { + onNone: () => ({ model }), + onSome: id => { + const label = model.labelInput + if (label === '' || Array.contains(shownLabels(model, id), label)) { + return { model } + } + return { + model: modifyFields(model, { + seq: seq => seq + 1, + lastLabel: () => model.seq, + labelError: () => '', + labelInput: () => '', + adding: Array.append({ seq: model.seq, id, label }), + }), + commands: [ChangeLabel({ seq: model.seq, id, label, kind: 'add' })], + } + }, + }), + + ClickedRemoveLabel: ({ label }) => + Option.match(model.maybeSelected, { + onNone: () => ({ model }), + onSome: id => ({ + model: modifyFields(model, { + seq: seq => seq + 1, + lastLabel: () => model.seq, + labelError: () => '', + removing: Array.append({ seq: model.seq, id, label }), + }), + commands: [ChangeLabel({ seq: model.seq, id, label, kind: 'remove' })], + }), + }), + + SucceededChangeLabel: ({ seq, id, labels }) => ({ + model: modifyFields(model, { + adding: dropGuess(seq), + removing: dropGuess(seq), + labels: current => (isSelected(model, id) && current._tag === 'Loaded' ? LabelsState.Loaded({ labels }) : current), + }), + }), + + FailedChangeLabel: ({ seq, error }) => ({ + model: modifyFields(model, { + adding: dropGuess(seq), + removing: dropGuess(seq), + labelError: current => (model.lastLabel === seq ? error : current), + }), + }), + }) + +const shownLabels = (model: Model, id: number): ReadonlyArray => + LabelsState.match(model.labels, { + Loading: () => [], + Failed: () => [], + Loaded: ({ labels }) => [ + ...Array.filter(labels, label => !Array.some(model.removing, change => change.id === id && change.label === label)), + ...pipe( + model.adding, + Array.filter(change => change.id === id), + Array.map(change => change.label), + ), + ], + }) + +// VIEW + +const testId = (h: HtmlBuilder, id: string) => h.DataAttribute('testid', id) + +const shownIssues = (model: Model, issues: ReadonlyArray): ReadonlyArray => + pipe( + issues, + Array.filter(issue => !(model.filter === 'open' && Array.some(model.closing, closing => closing.id === issue.id))), + Array.map(issue => ({ + ...issue, + title: Option.getOrElse(latestGuess(model.renaming, issue.id), () => issue.title), + assignee: Option.getOrElse(latestGuess(model.assigning, issue.id), () => issue.assignee), + })), + ) + +const listView = (model: Model, h: HtmlBuilder): Html => + ListState.match(model.list, { + Loading: () => h.p([], [`loading ${model.filter} issues`]), + Failed: ({ error }) => h.p([], [`list error: ${error}`]), + Loaded: ({ issues }) => + h.ul( + [], + Array.map(shownIssues(model, issues), issue => + h.keyed('li')(String(issue.id), [], [ + h.button( + [testId(h, 'issue'), h.OnClick(Message.ClickedIssue({ id: issue.id }))], + [`#${issue.id} ${issue.title} (${issue.assignee ?? 'unassigned'})`], + ), + ]), + ), + ), + }) + +const errorLine = (model: Model, maybeError: Option.Option, prefix: string, h: HtmlBuilder): Html => + pipe( + maybeError, + Option.filter(error => isSelected(model, error.id)), + Option.match({ onNone: () => h.empty, onSome: error => h.p([], [`${prefix}${error.message}`]) }), + ) + +const assignErrorLine = (model: Model, h: HtmlBuilder): Html => + pipe( + model.maybeAssignError, + Option.filter(error => isSelected(model, error.id)), + Option.match({ onNone: () => h.empty, onSome: error => h.p([testId(h, 'assign-error')], [error.message]) }), + ) + +const commentsView = (model: Model, id: number, h: HtmlBuilder): Html => + CommentsState.match(model.comments, { + Loading: () => h.p([], ['loading comments']), + Failed: ({ error }) => h.p([], [`comments error: ${error}`]), + Loaded: ({ comments }) => + h.ul([], [ + ...Array.map(comments, comment => h.li([testId(h, 'comment')], [`${comment.author}: ${comment.text}`])), + ...pipe( + model.sending, + Array.filter(sending => sending.id === id), + Array.map(sending => + h.keyed('li')(`s${sending.seq}`, [testId(h, 'comment')], [`${sending.author}: ${sending.text} (sending…)`]), + ), + ), + ]), + }) + +const detailBody = (model: Model, id: number, h: HtmlBuilder): Html => + DetailState.match(model.detail, { + Loading: () => h.p([], [`loading #${id}`]), + Failed: ({ error }) => h.p([], [`detail error: ${error}`]), + Loaded: ({ detail }) => + h.div([], [ + h.h2([], [`#${detail.id} ${Option.getOrElse(latestGuess(model.renaming, detail.id), () => detail.title)}`]), + h.p([], [detail.body]), + ]), + }) + +const labelsView = (model: Model, id: number, h: HtmlBuilder): Html => + h.div([], [ + h.div( + [testId(h, 'labels')], + [ + LabelsState.match(model.labels, { + Loading: () => h.p([], ['loading labels']), + Failed: ({ error }) => h.p([], [`labels error: ${error}`]), + Loaded: () => + h.ul( + [], + Array.map(shownLabels(model, id), label => + h.keyed('li')(label, [], [ + h.span([testId(h, 'label')], [label]), + ' ', + h.button([testId(h, 'remove-label'), h.OnClick(Message.ClickedRemoveLabel({ label }))], [`remove ${label}`]), + ]), + ), + ), + }), + ], + ), + h.input([testId(h, 'label-input'), h.Value(model.labelInput), h.OnInput(value => Message.UpdatedLabelInput({ value }))]), + h.button([testId(h, 'add-label'), h.OnClick(Message.ClickedAddLabel())], ['add label']), + model.labelError === '' ? h.empty : h.p([], [`label error: ${model.labelError}`]), + ]) + +const detailView = (model: Model, id: number, h: HtmlBuilder): ReadonlyArray => [ + detailBody(model, id, h), + h.button([testId(h, 'retry'), h.OnClick(Message.ClickedRetry())], ['retry']), + h.button([testId(h, 'assign'), h.OnClick(Message.ClickedAssign())], [`assign to ${model.user}`]), + assignErrorLine(model, h), + h.div([], [ + h.input([ + testId(h, 'title-input'), + h.Value(model.titleInput), + h.OnInput(value => Message.UpdatedTitleInput({ value })), + ]), + h.button([testId(h, 'rename'), h.OnClick(Message.ClickedRename())], ['rename']), + errorLine(model, model.maybeRenameError, 'rename error: ', h), + ]), + h.button([testId(h, 'close'), h.OnClick(Message.ClickedClose())], ['close']), + errorLine(model, model.maybeCloseError, 'close error: ', h), + labelsView(model, id, h), + h.div([testId(h, 'comments')], [commentsView(model, id, h)]), + h.input([ + testId(h, 'comment-input'), + h.Value(model.commentInput), + h.OnInput(value => Message.UpdatedCommentInput({ value })), + ]), + h.button([testId(h, 'post'), h.OnClick(Message.ClickedPost())], ['post']), + errorLine(model, model.maybePostError, 'post error: ', h), +] + +export const view = Submodel.defineView((model, h) => + h.div([], [ + h.section([], [ + h.label([], [ + 'user ', + h.input([testId(h, 'user'), h.Value(model.user), h.OnInput(value => Message.UpdatedUser({ value }))]), + ]), + h.div([], [ + h.button([testId(h, 'filter-open'), h.OnClick(Message.ClickedFilter({ filter: 'open' }))], ['open']), + h.button([testId(h, 'filter-closed'), h.OnClick(Message.ClickedFilter({ filter: 'closed' }))], ['closed']), + ]), + h.div([testId(h, 'list')], [listView(model, h)]), + ]), + h.section( + [testId(h, 'detail')], + Option.match(model.maybeSelected, { + onNone: () => [h.p([], ['select an issue'])], + onSome: id => detailView(model, id, h), + }), + ), + ]), +) diff --git a/eval/reference/labels/foldkit/main.ts b/eval/reference/labels/foldkit/main.ts new file mode 100644 index 0000000..5dba581 --- /dev/null +++ b/eval/reference/labels/foldkit/main.ts @@ -0,0 +1,130 @@ +import { Schema } from 'effect' +import { Command, type Runtime, type Update } from 'foldkit' +import { Document, Html, HtmlBuilder } from 'foldkit/html' +import { defineMessageUnion } from 'foldkit/message' +import { defineTaggedUnion } from 'foldkit/schema' + +import * as Help from './help' +import * as Issues from './issues' +import * as Settings from './settings' + +// MODEL + +const Tab = Schema.Literals(['issues', 'help', 'settings']) +type Tab = typeof Tab.Type + +// Each opening of a page gets a new `pageId`. Answers to a page that was left carry an old one and are dropped. +export const Page = defineTaggedUnion({ + Issues: { model: Issues.Model }, + Help: { model: Help.Model }, + Settings: { model: Settings.Model }, +}) + +export const Model = Schema.Struct({ pageId: Schema.Number, page: Page }) +export type Model = typeof Model.Type + +// MESSAGE + +export const Message = defineMessageUnion({ + ClickedTab: { tab: Tab }, + GotIssuesMessage: { pageId: Schema.Number, message: Issues.Message }, + GotHelpMessage: { pageId: Schema.Number, message: Help.Message }, + GotSettingsMessage: { pageId: Schema.Number, message: Settings.Message }, +}) +export type Message = typeof Message.Type + +// INIT + +const openIssues = (pageId: number, result: Update.Return): Update.Return => ({ + model: { pageId, page: Page.Issues({ model: result.model }) }, + commands: Command.mapMessages(result.commands, message => Message.GotIssuesMessage({ pageId, message })), +}) + +const openHelp = (pageId: number, result: Update.Return): Update.Return => ({ + model: { pageId, page: Page.Help({ model: result.model }) }, + commands: Command.mapMessages(result.commands, message => Message.GotHelpMessage({ pageId, message })), +}) + +const openSettings = ( + pageId: number, + result: Update.Return, +): Update.Return => ({ + model: { pageId, page: Page.Settings({ model: result.model }) }, + commands: Command.mapMessages(result.commands, message => Message.GotSettingsMessage({ pageId, message })), +}) + +export const init: Runtime.ApplicationInit = () => openIssues(1, Issues.init()) + +// UPDATE + +const tabOf = (page: typeof Page.Type): Tab => + Page.match(page, { Issues: () => 'issues', Help: () => 'help', Settings: () => 'settings' }) + +export const update = (model: Model, message: Message) => + Message.match>(message, { + ClickedTab: ({ tab }) => { + if (tab === tabOf(model.page)) { + return { model } + } + const pageId = model.pageId + 1 + return tab === 'issues' + ? openIssues(pageId, Issues.init()) + : tab === 'help' + ? openHelp(pageId, Help.init()) + : openSettings(pageId, Settings.init()) + }, + + GotIssuesMessage: ({ pageId, message }) => + model.page._tag === 'Issues' && pageId === model.pageId + ? openIssues(pageId, Issues.update(model.page.model, message)) + : { model }, + + GotHelpMessage: ({ pageId, message }) => + model.page._tag === 'Help' && pageId === model.pageId + ? openHelp(pageId, Help.update(model.page.model, message)) + : { model }, + + GotSettingsMessage: ({ pageId, message }) => + model.page._tag === 'Settings' && pageId === model.pageId + ? openSettings(pageId, Settings.update(model.page.model, message)) + : { model }, + }) + +// VIEW + +const pageView = (model: Model, h: HtmlBuilder): Html => + Page.match(model.page, { + Issues: page => + h.submodel({ + slotId: `issues-${model.pageId}`, + model: page.model, + view: Issues.view, + toParentMessage: message => Message.GotIssuesMessage({ pageId: model.pageId, message }), + }), + Help: page => + h.submodel({ + slotId: `help-${model.pageId}`, + model: page.model, + view: Help.view, + toParentMessage: message => Message.GotHelpMessage({ pageId: model.pageId, message }), + }), + Settings: page => + h.submodel({ + slotId: `settings-${model.pageId}`, + model: page.model, + view: Settings.view, + toParentMessage: message => Message.GotSettingsMessage({ pageId: model.pageId, message }), + }), + }) + +export const view = (model: Model, h: HtmlBuilder): Document => ({ + title: 'Issue desk', + body: h.div([], [ + h.nav([], [ + h.button([h.DataAttribute('testid', 'tab-issues'), h.OnClick(Message.ClickedTab({ tab: 'issues' }))], ['Issues']), + h.button([h.DataAttribute('testid', 'tab-help'), h.OnClick(Message.ClickedTab({ tab: 'help' }))], ['Help']), + h.button([h.DataAttribute('testid', 'tab-settings'), h.OnClick(Message.ClickedTab({ tab: 'settings' }))], ['Settings']), + ]), + pageView(model, h), + ]), +}) diff --git a/eval/reference/labels/foldkit/request.ts b/eval/reference/labels/foldkit/request.ts new file mode 100644 index 0000000..d744b31 --- /dev/null +++ b/eval/reference/labels/foldkit/request.ts @@ -0,0 +1,7 @@ +import { Effect } from 'effect' + +export const errorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error) + +export const request = (call: () => Promise): Effect.Effect => + Effect.tryPromise({ try: call, catch: errorMessage }) diff --git a/eval/reference/labels/foldkit/settings.ts b/eval/reference/labels/foldkit/settings.ts new file mode 100644 index 0000000..01f5807 --- /dev/null +++ b/eval/reference/labels/foldkit/settings.ts @@ -0,0 +1,201 @@ +import { Array, Effect, Option, Schema, pipe } from 'effect' +import { Command, Submodel, type Update } from 'foldkit' +import { Html, HtmlBuilder } from 'foldkit/html' +import { defineMessageUnion } from 'foldkit/message' +import { defineTaggedUnion } from 'foldkit/schema' +import { modifyFields } from 'foldkit/struct' + +import * as Api from './api' +import { request } from './request' + +// MODEL + +const Theme = Schema.Literals(['light', 'dark']) +const Density = Schema.Literals(['comfortable', 'compact']) +const Settings = Schema.Struct({ name: Schema.String, theme: Theme, density: Density }) +type Settings = typeof Settings.Type + +// The draft is seeded once from the loaded settings; after that it is the user's. +export const LoadState = defineTaggedUnion({ + Loading: {}, + Failed: { error: Schema.String }, + Loaded: { saved: Settings, draft: Settings }, +}) + +export const Model = Schema.Struct({ + loadSeq: Schema.Number, + load: LoadState, + seq: Schema.Number, + saving: Schema.Array(Schema.Struct({ seq: Schema.Number, value: Settings })), + confirmed: Schema.Number, + maybeSaveError: Schema.Option(Schema.String), +}) +export type Model = typeof Model.Type + +// MESSAGE + +export const Message = defineMessageUnion({ + SucceededLoad: { seq: Schema.Number, settings: Settings }, + FailedLoad: { seq: Schema.Number, error: Schema.String }, + ClickedRetryLoad: {}, + UpdatedName: { value: Schema.String }, + ClickedTheme: { theme: Theme }, + ClickedDensityToggle: {}, + ClickedSave: {}, + SucceededSave: { seq: Schema.Number, settings: Settings }, + FailedSave: { seq: Schema.Number, error: Schema.String }, +}) +export type Message = typeof Message.Type + +// COMMAND + +const Load = Command.define('Load', { + args: { seq: Schema.Number }, + messages: [Message.SucceededLoad, Message.FailedLoad], + execute: ({ seq }) => + request(() => Api.loadSettings()).pipe( + Effect.map(settings => Message.SucceededLoad({ seq, settings })), + Effect.catch(error => Effect.succeed(Message.FailedLoad({ seq, error }))), + ), +}) + +const Save = Command.define('Save', { + args: { seq: Schema.Number, settings: Settings }, + messages: [Message.SucceededSave, Message.FailedSave], + execute: ({ seq, settings }) => + request(() => Api.saveSettings(settings)).pipe( + Effect.map(saved => Message.SucceededSave({ seq, settings: saved })), + Effect.catch(error => Effect.succeed(Message.FailedSave({ seq, error }))), + ), +}) + +// INIT + +export const init = (): Update.Return => ({ + model: { loadSeq: 1, load: LoadState.Loading(), seq: 1, saving: [], confirmed: 0, maybeSaveError: Option.none() }, + commands: [Load({ seq: 1 })], +}) + +// UPDATE + +const nameError = (name: string): string => + name.length === 0 + ? 'Display name is required' + : name.length > 20 + ? `Display name must be at most 20 characters, not ${name.length}` + : '' + +const editDraft = (model: Model, f: (draft: Settings) => Settings): Model => + modifyFields(model, { + load: load => (load._tag === 'Loaded' ? LoadState.Loaded({ saved: load.saved, draft: f(load.draft) }) : load), + }) + +export const update = (model: Model, message: Message) => + Message.match>(message, { + SucceededLoad: ({ seq, settings }) => + seq === model.loadSeq + ? { model: modifyFields(model, { load: () => LoadState.Loaded({ saved: settings, draft: settings }) }) } + : { model }, + + FailedLoad: ({ seq, error }) => + seq === model.loadSeq ? { model: modifyFields(model, { load: () => LoadState.Failed({ error }) }) } : { model }, + + ClickedRetryLoad: () => { + const loadSeq = model.loadSeq + 1 + return { model: modifyFields(model, { loadSeq: () => loadSeq }), commands: [Load({ seq: loadSeq })] } + }, + + UpdatedName: ({ value }) => ({ model: editDraft(model, draft => ({ ...draft, name: value })) }), + + ClickedTheme: ({ theme }) => ({ model: editDraft(model, draft => ({ ...draft, theme })) }), + + ClickedDensityToggle: () => ({ + model: editDraft(model, draft => ({ ...draft, density: draft.density === 'compact' ? 'comfortable' : 'compact' })), + }), + + ClickedSave: () => { + if (model.load._tag !== 'Loaded' || nameError(model.load.draft.name) !== '') { + return { model } + } + const draft = model.load.draft + return { + model: modifyFields(model, { + seq: seq => seq + 1, + maybeSaveError: () => Option.none(), + saving: Array.append({ seq: model.seq, value: draft }), + }), + commands: [Save({ seq: model.seq, settings: draft })], + } + }, + + SucceededSave: ({ seq, settings }) => { + const isNewest = seq > model.confirmed + return { + model: modifyFields(model, { + saving: Array.filter(saving => saving.seq !== seq), + confirmed: confirmed => (isNewest ? seq : confirmed), + load: load => + isNewest && load._tag === 'Loaded' ? LoadState.Loaded({ saved: settings, draft: load.draft }) : load, + }), + } + }, + + FailedSave: ({ seq, error }) => ({ + model: modifyFields(model, { + saving: Array.filter(saving => saving.seq !== seq), + maybeSaveError: () => Option.some(error), + }), + }), + }) + +// VIEW + +const testId = (h: HtmlBuilder, id: string) => h.DataAttribute('testid', id) + +const loadedView = (model: Model, saved: Settings, draft: Settings, h: HtmlBuilder): Html => { + const maybeGuess = pipe( + model.saving, + Array.filter(saving => saving.seq > model.confirmed), + Array.last, + ) + const shown = Option.match(maybeGuess, { onNone: () => saved, onSome: guess => guess.value }) + const error = nameError(draft.name) + return h.main([], [ + h.p( + [testId(h, 'saved')], + [`Saved: ${shown.name} · ${shown.theme} · ${shown.density}${Option.isSome(maybeGuess) ? ' (saving…)' : ''}`], + ), + Option.match(model.maybeSaveError, { + onNone: () => h.empty, + onSome: saveError => h.p([testId(h, 'save-error')], [`Save failed: ${saveError}`]), + }), + h.div([], [ + h.label([], [ + 'Display name ', + h.input([testId(h, 'name'), h.Value(draft.name), h.OnInput(value => Message.UpdatedName({ value }))]), + ]), + h.small([testId(h, 'name-count')], [`${draft.name.length}/20`]), + error === '' ? h.empty : h.p([testId(h, 'name-error')], [error]), + h.div([], [ + h.span([], [`Theme: ${draft.theme}`]), + h.button([testId(h, 'theme-light'), h.OnClick(Message.ClickedTheme({ theme: 'light' }))], ['light']), + h.button([testId(h, 'theme-dark'), h.OnClick(Message.ClickedTheme({ theme: 'dark' }))], ['dark']), + h.button([testId(h, 'density-toggle'), h.OnClick(Message.ClickedDensityToggle())], ['Toggle density']), + h.p([testId(h, 'preview')], [`Preview: ${draft.theme} / ${draft.density}`]), + ]), + h.button([testId(h, 'save'), h.OnClick(Message.ClickedSave())], ['Save']), + ]), + ]) +} + +export const view = Submodel.defineView((model, h) => + LoadState.match(model.load, { + Loading: () => h.p([testId(h, 'load')], ['Loading settings…']), + Failed: ({ error }) => + h.section([], [ + h.p([testId(h, 'load')], [`Could not load settings: ${error}`]), + h.button([testId(h, 'retry-load'), h.OnClick(Message.ClickedRetryLoad())], ['Retry']), + ]), + Loaded: ({ saved, draft }) => loadedView(model, saved, draft, h), + }), +) diff --git a/eval/reference/labels/react/App.tsx b/eval/reference/labels/react/App.tsx new file mode 100644 index 0000000..f36876b --- /dev/null +++ b/eval/reference/labels/react/App.tsx @@ -0,0 +1,42 @@ +import { useState, type ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import IssuesPage from "./IssuesPage"; +import HelpPage from "./HelpPage"; +import SettingsPage from "./SettingsPage"; + +type Page = "issues" | "help" | "settings"; + +// Each page gets its own query cache, made when the page opens, so leaving a page discards what it loaded. +function PageClient({ children }: { children: ReactNode }) { + const [client] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, refetchOnWindowFocus: false, refetchOnReconnect: false }, + }, + }), + ); + return {children}; +} + +export default function App() { + const [page, setPage] = useState("issues"); + return ( +
    + + + {page === "issues" ? : page === "help" ? : } + +
    + ); +} diff --git a/eval/reference/labels/react/CommentList.tsx b/eval/reference/labels/react/CommentList.tsx new file mode 100644 index 0000000..3218198 --- /dev/null +++ b/eval/reference/labels/react/CommentList.tsx @@ -0,0 +1,30 @@ +import type { Comment } from "./api"; +import { errorMessage } from "./errors"; + +export type Sending = { seq: number; issueId: number; author: string; text: string }; + +type Props = { + comments: Comment[] | undefined; + isPending: boolean; + error: unknown; + sending: Sending[]; +}; + +export default function CommentList({ comments, isPending, error, sending }: Props) { + if (isPending) return

    loading comments

    ; + if (error || !comments) return

    comments error: {errorMessage(error)}

    ; + return ( +
      + {comments.map((c, i) => ( +
    • + {c.author}: {c.text} +
    • + ))} + {sending.map(p => ( +
    • + {p.author}: {p.text} (sending…) +
    • + ))} +
    + ); +} diff --git a/eval/reference/labels/react/HelpPage.tsx b/eval/reference/labels/react/HelpPage.tsx new file mode 100644 index 0000000..020d0ed --- /dev/null +++ b/eval/reference/labels/react/HelpPage.tsx @@ -0,0 +1,84 @@ +import { useState } from "react"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { fetchItem, search, type Hit } from "./api"; + +export default function HelpPage() { + const [query, setQuery] = useState(""); + const [selected, setSelected] = useState(null); + return ( +
    + setQuery(e.target.value)} />{" "} + {query !== "" && }{" "} + +
    + ); +} + +function Results({ query, selected, onSelect }: { query: string; selected: number | null; onSelect: (id: number) => void }) { + const tooShort = query.length < 2; + const { data, error, isFetching } = useQuery({ + queryKey: ["search", query], + queryFn: () => search(query), + enabled: !tooShort, + placeholderData: keepPreviousData, + }); + if (tooShort) + return ( +
    +

    too short: {query}

    +
    + ); + return ( +
    + {isFetching &&

    searching…

    } + {error && !isFetching ? ( +

    search failed: {error.message}

    + ) : ( + data && ( +
    +

    + {data.length} results for {query} +

    +
      + {data.map((hit: Hit) => ( +
    • onSelect(hit.id)}> + {hit.id === selected ? "> " : ""} + {hit.name} +
    • + ))} +
    +
    + ) + )} +
    + ); +} + +function HelpItem({ id }: { id: number | null }) { + const { data, error, isPending, refetch } = useQuery({ + queryKey: ["item", id], + queryFn: () => fetchItem(id!), + enabled: id !== null, + }); + if (id === null) return

    pick a result

    ; + return ( +
    + {isPending ? ( +

    loading…

    + ) : error ? ( + <> +

    failed: {error.message}

    {" "} + + + ) : data === null ? ( +

    item {id} not found

    + ) : ( +
    +

    {data.name}

    {data.blurb}

    +
    + )} +
    + ); +} diff --git a/eval/reference/labels/react/IssueDetail.tsx b/eval/reference/labels/react/IssueDetail.tsx new file mode 100644 index 0000000..8271510 --- /dev/null +++ b/eval/reference/labels/react/IssueDetail.tsx @@ -0,0 +1,76 @@ +import type { Comment, IssueDetail as Detail } from "./api"; +import { errorMessage } from "./errors"; +import CommentList, { type Sending } from "./CommentList"; +import Labels from "./Labels"; + +type Props = { + id: number; + user: string; + detail: Detail | undefined; + loading: boolean; + error: unknown; + onRetry: () => void; + onAssign: () => void; + assignError: string | null; + titleInput: string; + onTitleInput: (title: string) => void; + onRename: () => void; + renameError: string | null; + onClose: () => void; + closeError: string | null; + comments: Comment[] | undefined; + commentsPending: boolean; + commentsError: unknown; + sending: Sending[]; + commentInput: string; + onCommentInput: (text: string) => void; + onPost: () => void; + postError: string | null; +}; + +export default function IssueDetail(p: Props) { + let body; + if (p.loading) body =

    loading #{p.id}

    ; + else if (p.error || !p.detail) body =

    detail error: {errorMessage(p.error)}

    ; + else + body = ( + <> +

    + #{p.detail.id} {p.detail.title} +

    +

    {p.detail.body}

    + + ); + return ( + <> + {body} + + + {p.assignError &&

    {p.assignError}

    } +
    + p.onTitleInput(e.target.value)} /> + + {p.renameError &&

    rename error: {p.renameError}

    } +
    + + {p.closeError &&

    close error: {p.closeError}

    } + +
    + +
    + p.onCommentInput(e.target.value)} /> + + {p.postError &&

    post error: {p.postError}

    } + + ); +} diff --git a/eval/reference/labels/react/IssueList.tsx b/eval/reference/labels/react/IssueList.tsx new file mode 100644 index 0000000..576ad8b --- /dev/null +++ b/eval/reference/labels/react/IssueList.tsx @@ -0,0 +1,27 @@ +import type { Issue } from "./api"; +import { errorMessage } from "./errors"; + +type Props = { + filter: "open" | "closed"; + issues: Issue[] | undefined; + isPending: boolean; + error: unknown; + selected: number | null; + onSelect: (id: number) => void; +}; + +export default function IssueList({ filter, issues, isPending, error, selected, onSelect }: Props) { + if (isPending) return

    loading {filter} issues

    ; + if (error || !issues) return

    list error: {errorMessage(error)}

    ; + return ( +
      + {issues.map(issue => ( +
    • + +
    • + ))} +
    + ); +} diff --git a/eval/reference/labels/react/IssuesPage.tsx b/eval/reference/labels/react/IssuesPage.tsx new file mode 100644 index 0000000..9e16523 --- /dev/null +++ b/eval/reference/labels/react/IssuesPage.tsx @@ -0,0 +1,233 @@ +import { useRef, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { addComment, assign, closeIssue, fetchComments, fetchIssue, fetchIssues, renameIssue } from "./api"; +import type { Issue, IssueDetail as Detail } from "./api"; +import { errorMessage, type IssueError } from "./errors"; +import IssueList from "./IssueList"; +import IssueDetail from "./IssueDetail"; +import type { Sending } from "./CommentList"; + +type Filter = "open" | "closed"; +// a write still in the air: `seq` orders writes to the same issue, the latest one is what the issue shows +type Guess = { seq: number; value: T }; +type Guesses = Record[]>; + +const latest = (guesses: Guesses, id: number): T | undefined => guesses[id]?.at(-1)?.value; +const addGuess = (prev: Guesses, id: number, g: Guess): Guesses => ({ ...prev, [id]: [...(prev[id] ?? []), g] }); +const dropGuess = (prev: Guesses, id: number, seq: number): Guesses => { + const rest = (prev[id] ?? []).filter(g => g.seq !== seq); + const next = { ...prev }; + if (rest.length) next[id] = rest; + else delete next[id]; + return next; +}; + +export default function IssuesPage() { + const queryClient = useQueryClient(); + const [user, setUser] = useState("guest"); + const [filter, setFilter] = useState("open"); + const [selected, setSelected] = useState(null); + const seqRef = useRef(0); + const nextSeq = () => ++seqRef.current; + + // assignments in the air, and the newest one confirmed per issue (an older answer must not overwrite it) + const [assigning, setAssigning] = useState>({}); + const [assignError, setAssignError] = useState(null); + const lastAssignRef = useRef(0); + const confirmedAssignRef = useRef>({}); + + const [renaming, setRenaming] = useState>({}); + const [renameError, setRenameError] = useState(null); + const [titleInput, setTitleInput] = useState(""); + const lastRenameRef = useRef(0); + const confirmedRenameRef = useRef>({}); + + // closes in the air, by issue id, with how many are out + const [closing, setClosing] = useState>({}); + const [closeError, setCloseError] = useState(null); + const lastCloseRef = useRef(0); + + const [sending, setSending] = useState([]); + const [postError, setPostError] = useState(null); + const [commentInput, setCommentInput] = useState(""); + const lastPostRef = useRef(0); + + const list = useQuery({ queryKey: ["issues", filter], queryFn: () => fetchIssues(filter) }); + + const detail = useQuery({ + queryKey: ["issue", selected], + queryFn: async () => { + const id = selected as number; + const d = await fetchIssue(id); + if (d === null) throw new Error(`issue #${id} not found`); + return d; + }, + enabled: selected !== null, + }); + + const comments = useQuery({ + queryKey: ["comments", selected], + queryFn: () => fetchComments(selected as number), + enabled: selected !== null, + }); + + const shownIssues = list.data + ?.filter(issue => !(filter === "open" && closing[issue.id])) + .map(issue => ({ + ...issue, + title: latest(renaming, issue.id) ?? issue.title, + assignee: latest(assigning, issue.id) ?? issue.assignee, + })); + const shownDetail = detail.data && { ...detail.data, title: latest(renaming, detail.data.id) ?? detail.data.title }; + + const select = (id: number) => { + setSelected(id); + setTitleInput(""); + setAssignError(null); + setRenameError(null); + setCloseError(null); + setPostError(null); + }; + + const doAssign = async (id: number, who: string) => { + const seq = nextSeq(); + lastAssignRef.current = seq; + setAssignError(null); + setAssigning(prev => addGuess(prev, id, { seq, value: who })); + try { + const res = await assign(id, who); + if ((confirmedAssignRef.current[id] ?? 0) < seq) { + confirmedAssignRef.current[id] = seq; + queryClient.setQueriesData({ queryKey: ["issues"] }, old => + old?.map(i => (i.id === id ? { ...i, assignee: res.assignee } : i)), + ); + } + } catch (e) { + // only report the failure if no later assignment has started since + if (lastAssignRef.current === seq) setAssignError({ issueId: id, message: errorMessage(e) }); + } finally { + setAssigning(prev => dropGuess(prev, id, seq)); + } + }; + + const doRename = async (id: number, title: string) => { + const seq = nextSeq(); + lastRenameRef.current = seq; + setRenameError(null); + setRenaming(prev => addGuess(prev, id, { seq, value: title })); + try { + const res = await renameIssue(id, title); + if ((confirmedRenameRef.current[id] ?? 0) < seq) { + confirmedRenameRef.current[id] = seq; + queryClient.setQueriesData({ queryKey: ["issues"] }, old => + old?.map(i => (i.id === id ? { ...i, title: res.title } : i)), + ); + queryClient.setQueryData(["issue", id], old => (old ? { ...old, title: res.title } : old)); + } + } catch (e) { + if (lastRenameRef.current === seq) setRenameError({ issueId: id, message: errorMessage(e) }); + } finally { + setRenaming(prev => dropGuess(prev, id, seq)); + } + }; + + const doClose = async (id: number) => { + const seq = nextSeq(); + lastCloseRef.current = seq; + setCloseError(null); + setClosing(prev => ({ ...prev, [id]: (prev[id] ?? 0) + 1 })); + try { + await closeIssue(id); + queryClient.setQueryData(["issues", "open"], old => old?.filter(i => i.id !== id)); + } catch (e) { + if (lastCloseRef.current === seq) setCloseError({ issueId: id, message: errorMessage(e) }); + } finally { + setClosing(prev => { + const next = { ...prev }; + if ((next[id] ?? 0) > 1) next[id] -= 1; + else delete next[id]; + return next; + }); + } + }; + + const doPost = async (id: number, author: string, text: string) => { + const seq = nextSeq(); + lastPostRef.current = seq; + setPostError(null); + setCommentInput(""); + setSending(prev => [...prev, { seq, issueId: id, author, text }]); + try { + const res = await addComment(id, author, text); + queryClient.setQueryData<{ author: string; text: string }[]>(["comments", id], old => (old ? [...old, res] : old)); + } catch (e) { + if (lastPostRef.current === seq) setPostError({ issueId: id, message: errorMessage(e) }); + } finally { + setSending(prev => prev.filter(p => p.seq !== seq)); + } + }; + + const errorFor = (e: IssueError | null) => (e && e.issueId === selected ? e.message : null); + + return ( +
    +
    + +
    + + +
    +
    + +
    +
    +
    + {selected === null ? ( +

    select an issue

    + ) : ( + void detail.refetch()} + onAssign={() => void doAssign(selected, user)} + assignError={errorFor(assignError)} + titleInput={titleInput} + onTitleInput={setTitleInput} + onRename={() => { + if (titleInput !== "") void doRename(selected, titleInput); + }} + renameError={errorFor(renameError)} + onClose={() => void doClose(selected)} + closeError={errorFor(closeError)} + comments={comments.data} + commentsPending={comments.isPending} + commentsError={comments.error} + sending={sending.filter(p => p.issueId === selected)} + commentInput={commentInput} + onCommentInput={setCommentInput} + onPost={() => { + if (commentInput !== "") void doPost(selected, user, commentInput); + }} + postError={errorFor(postError)} + /> + )} +
    +
    + ); +} diff --git a/eval/reference/labels/react/Labels.tsx b/eval/reference/labels/react/Labels.tsx new file mode 100644 index 0000000..2a922de --- /dev/null +++ b/eval/reference/labels/react/Labels.tsx @@ -0,0 +1,77 @@ +import { useEffect, useRef, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { addLabel, fetchLabels, removeLabel } from "./api"; +import { errorMessage } from "./errors"; + +// a label change in the air, on one issue +type Change = { seq: number; issueId: number; label: string }; + +export default function Labels({ id }: { id: number }) { + const queryClient = useQueryClient(); + const labels = useQuery({ queryKey: ["labels", id], queryFn: () => fetchLabels(id) }); + const [adding, setAdding] = useState([]); + const [removing, setRemoving] = useState([]); + const [labelError, setLabelError] = useState(""); + const [labelInput, setLabelInput] = useState(""); + const seqRef = useRef(0); + + // a label error belongs to the issue it was made on: selecting another clears it + useEffect(() => setLabelError(""), [id]); + + const shown = [ + ...(labels.data ?? []).filter(l => !removing.some(r => r.issueId === id && r.label === l)), + ...adding.filter(a => a.issueId === id).map(a => a.label), + ]; + + const change = async (kind: "add" | "remove", issueId: number, label: string) => { + const seq = ++seqRef.current; + const set = kind === "add" ? setAdding : setRemoving; + setLabelError(""); + set(prev => [...prev, { seq, issueId, label }]); + try { + const after = await (kind === "add" ? addLabel(issueId, label) : removeLabel(issueId, label)); + queryClient.setQueryData(["labels", issueId], after); + } catch (e) { + if (seqRef.current === seq) setLabelError(errorMessage(e)); + } finally { + set(prev => prev.filter(c => c.seq !== seq)); + } + }; + + return ( +
    +
    + {labels.isPending ? ( +

    loading labels

    + ) : labels.isError ? ( +

    labels error: {errorMessage(labels.error)}

    + ) : ( +
      + {shown.map(l => ( +
    • + {l}{" "} + +
    • + ))} +
    + )} +
    + setLabelInput(e.target.value)} /> + + {labelError &&

    label error: {labelError}

    } +
    + ); +} diff --git a/eval/reference/labels/react/SettingsPage.tsx b/eval/reference/labels/react/SettingsPage.tsx new file mode 100644 index 0000000..d4ad74e --- /dev/null +++ b/eval/reference/labels/react/SettingsPage.tsx @@ -0,0 +1,76 @@ +import { useRef, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { loadSettings, saveSettings, type Density, type Settings, type Theme } from "./api"; + +export default function SettingsPage() { + const queryClient = useQueryClient(); + const settings = useQuery({ queryKey: ["settings"], queryFn: loadSettings }); + const [saveError, setSaveError] = useState(""); + // saves still in the air, oldest first; the newest one shows until it answers or a newer one is confirmed + const [saving, setSaving] = useState<{ seq: number; value: Settings }[]>([]); + const seqRef = useRef(0); + const confirmedRef = useRef(0); + + const save = async (next: Settings) => { + const seq = ++seqRef.current; + setSaveError(""); + setSaving(prev => [...prev, { seq, value: next }]); + try { + const saved = await saveSettings(next); + if (seq > confirmedRef.current) { + confirmedRef.current = seq; + queryClient.setQueryData(["settings"], saved); + } + } catch (e) { + setSaveError(e instanceof Error ? e.message : String(e)); + } finally { + setSaving(prev => prev.filter(p => p.seq !== seq)); + } + }; + + if (settings.isPending) return

    Loading settings…

    ; + if (settings.error) return ( +
    +

    Could not load settings: {settings.error.message}

    {" "} + +
    + ); + const guess = saving.filter(p => p.seq > confirmedRef.current).at(-1); + const s = guess?.value ?? settings.data; + return ( +
    +

    Saved: {s.name} · {s.theme} · {s.density}{guess ? " (saving…)" : ""}

    {" "} + {saveError &&

    Save failed: {saveError}

    } + void save(draft)} /> +
    + ); +} + +function nameError(name: string) { + if (name.length === 0) return "Display name is required"; + if (name.length > 20) return `Display name must be at most 20 characters, not ${name.length}`; + return ""; +} + +// Seeded once from the loaded settings; after that the draft is the user's. +function SettingsForm({ initial, onSave }: { initial: Settings; onSave: (draft: Settings) => void }) { + const [name, setName] = useState(initial.name); + const [theme, setTheme] = useState(initial.theme); + const [density, setDensity] = useState(initial.density); + const error = nameError(name); + return ( +
    + {" "} + {name.length}/20{" "} + {error &&

    {error}

    } +
    + Theme: {theme}{" "} + {" "} + {" "} + {" "} +

    Preview: {theme} / {density}

    +
    + +
    + ); +} diff --git a/eval/reference/labels/react/errors.ts b/eval/reference/labels/react/errors.ts new file mode 100644 index 0000000..c380616 --- /dev/null +++ b/eval/reference/labels/react/errors.ts @@ -0,0 +1,4 @@ +export const errorMessage = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +/** an error that belongs to one issue: shown only while that issue is selected */ +export type IssueError = { issueId: number; message: string }; diff --git a/eval/reference/labels/rx/comments.rx b/eval/reference/labels/rx/comments.rx new file mode 100644 index 0000000..3e9b7b2 --- /dev/null +++ b/eval/reference/labels/rx/comments.rx @@ -0,0 +1,55 @@ +// The selected issue's comments, and posting one. Posts in flight live in their own source, so changing the +// selection never waits for them, and each shows only under its own issue. + +import pure { fetchComments } from "./api" +import { addComment } from "./api" + +export component Comments(props: { id: number; user: string }) { + source comments = await fetchComments(props.id) + source sending: { id: number; author: string; text: string }[] = [] + shownSending = sending.filter(s => s.id == props.id) + source postError: string | null = null + source commentInput: string := "" + + // a post error belongs to the issue it was made on: selecting another clears it + effect (props.id) { postError := null } + + action postComment(id: number, author: string, text: string) { + optimistic postError := null + try { + optimistic sending := [...sending, { id: id, author: author, text: text }] + await addComment(id, author, text) + refresh comments + } catch (e) { + postError := e.message + } + } + +
    +
    + {loading on(props.id) fallback {

    loading comments

    } { + {errored fallback(e) {

    comments error: {e.message}

    } { +
      + {for c in comments { +
    • {c.author}: {c.text}
    • + }} + {for s in shownSending { +
    • {s.author}: {s.text} (sending…)
    • + }} +
    + }} + }} +
    + { commentInput := e.target.value }} /> + + {if postError != null { +

    post error: {postError}

    + }} +
    +} diff --git a/eval/reference/labels/rx/help.rx b/eval/reference/labels/rx/help.rx new file mode 100644 index 0000000..7217eef --- /dev/null +++ b/eval/reference/labels/rx/help.rx @@ -0,0 +1,52 @@ +// The help page: search the help catalogue and open one result. + +import pure { search, fetchItem } from "./api" + +export error TooShort(q: string) => `too short: ${q}` +export error ItemNotFound(id: number) => `item ${id} not found` + +fn foundItem(it, id: number) => it == null ? throw ItemNotFound(id) : it + +export component HelpPage() { + source query: string := "" + source selected: number | null := null + results = query == "" ? [] : query.length < 2 ? throw TooShort(query) : await search(query) + item = selected == null ? null : foundItem(await fetchItem(selected), selected) + +
    + { query := e.target.value }} /> + {if query != "" { +
    + {errored fallback(e) {

    search failed: {e.message}

    } { + errored fallback(e: TooShort) {

    {e.message}

    } { + loading fallback {

    searching…

    } { +

    {pending? results ? "searching…" : ""}

    +

    {results.length} results for {latest query}

    +
      + {for r in results { +
    • { selected := r.id }}>{r.id == selected ? "> " : ""}{r.name}
    • + }} +
    + } + } + }} +
    + }} + {if selected == null { +

    pick a result

    + } else { +
    + {loading on(selected) fallback {

    loading…

    } { + {for id in [selected] { + {errored fallback(e) {

    failed: {e.message}

    } { + {errored fallback(e: ItemNotFound) {

    {e.message}

    } { +

    {item.name}

    +

    {item.blurb}

    + }} + }} + }} + }} +
    + }} +
    +} diff --git a/eval/reference/labels/rx/issues.rx b/eval/reference/labels/rx/issues.rx new file mode 100644 index 0000000..73d5be5 --- /dev/null +++ b/eval/reference/labels/rx/issues.rx @@ -0,0 +1,123 @@ +// The issues page: the filtered list, the selected issue's detail, and the writes on it (assign, rename, close). + +import pure { fetchIssues, fetchIssue } from "./api" +import { assign, renameIssue, closeIssue } from "./api" +import { Comments } from "./comments.rx" +import { Labels } from "./labels.rx" + +export error NotFound(id: number) => `issue #${id} not found` + +fn found(it, id: number) => it == null ? throw NotFound(id) : it + +export component IssuesPage() { + source user: string := "guest" + source filter: string := "open" + source selected: number | null := null + source issues key id = await fetchIssues(filter) + source detail = selected == null ? null : found(await fetchIssue(selected), selected) + source titleInput: string := "" + source assignError: string = "" + source renameError: string = "" + source closeError: string = "" + + action assignTo(id: number, who: string) { + optimistic assignError := "" + try { + let i = issues.findIndex(t => t.id == id) + optimistic issues[i].assignee := who + await assign(id, who) + refresh issues + } catch (e) { + assignError := e.message + } + } + + action renameTo(id: number, title: string) { + optimistic renameError := "" + try { + let i = issues.findIndex(t => t.id == id) + if (i >= 0) { + optimistic issues[i].title := title + } + if (detail != null && detail.id == id) { + optimistic detail.title := title + } + await renameIssue(id, title) + refresh issues + refresh detail + } catch (e) { + renameError := e.message + } + } + + action closeOne(id: number) { + optimistic closeError := "" + try { + optimistic issues := issues.filter(t => t.id != id) + await closeIssue(id) + refresh issues + } catch (e) { + closeError := e.message + } + } + +
    +
    + +
    +
    + + +
    +
    + {loading on(filter) fallback {

    loading {latest filter} issues

    } { + {errored fallback(e) {

    list error: {e.message}

    } { +
      + {for it in issues { +
    • { + selected := it.id + titleInput := "" + onFlush(() => { + assignError := "" + renameError := "" + closeError := "" + }) + }}>#{it.id} {it.title} ({it.assignee ?? "unassigned"})
    • + }} +
    + }} + }} +
    +
    + {if selected != null { + {loading on(selected) fallback {

    loading #{latest selected}

    } { + {errored fallback(e) {

    detail error: {e.message}

    } { +

    #{detail.id} {detail.title}

    +

    {detail.body}

    + }} + + }} + + {if assignError != "" { +

    {assignError}

    + }} + { titleInput := e.target.value }} /> + + {if renameError != "" { +

    rename error: {renameError}

    + }} + + {if closeError != "" { +

    close error: {closeError}

    + }} + + + } else { +

    select an issue

    + }} +
    +
    +} diff --git a/eval/reference/labels/rx/labels.rx b/eval/reference/labels/rx/labels.rx new file mode 100644 index 0000000..052b752 --- /dev/null +++ b/eval/reference/labels/rx/labels.rx @@ -0,0 +1,67 @@ +// The selected issue's labels, adding and removing them. Changes in flight live in their own sources, so changing +// the selection never waits for them, and each shows only under its own issue. + +import pure { fetchLabels } from "./api" +import { addLabel, removeLabel } from "./api" + +export component Labels(props: { id: number }) { + source labels = await fetchLabels(props.id) + source adding: { id: number; label: string }[] = [] + source removing: { id: number; label: string }[] = [] + shown = [ + ...labels.filter(l => !removing.some(r => r.id == props.id && r.label == l)), + ...adding.filter(a => a.id == props.id).map(a => a.label), + ] + source labelError: string = "" + source labelInput: string := "" + + // a label error belongs to the issue it was made on: selecting another clears it + effect (props.id) { labelError := "" } + + action add(id: number, label: string) { + optimistic labelError := "" + try { + optimistic adding := [...adding, { id: id, label: label }] + await addLabel(id, label) + refresh labels + } catch (e) { + labelError := e.message + } + } + + action remove(id: number, label: string) { + optimistic labelError := "" + try { + optimistic removing := [...removing, { id: id, label: label }] + await removeLabel(id, label) + refresh labels + } catch (e) { + labelError := e.message + } + } + +
    +
    + {loading on(props.id) fallback {

    loading labels

    } { + {errored fallback(e) {

    labels error: {e.message}

    } { +
      + {for l in shown { +
    • {l}{" "}
    • + }} +
    + }} + }} +
    + { labelInput := e.target.value }} /> + + {if labelError != "" { +

    label error: {labelError}

    + }} +
    +} diff --git a/eval/reference/labels/rx/main.rx b/eval/reference/labels/rx/main.rx new file mode 100644 index 0000000..ffe006c --- /dev/null +++ b/eval/reference/labels/rx/main.rx @@ -0,0 +1,26 @@ +// The desk: three pages behind tabs. Only the open page is mounted, so leaving one discards its state. + +import { IssuesPage } from "./issues.rx" +import { HelpPage } from "./help.rx" +import { SettingsPage } from "./settings.rx" + +export default component App() { + source page: string := "issues" + +
    + + {if page == "issues" { + + } else { + {if page == "help" { + + } else { + + }} + }} +
    +} diff --git a/eval/reference/labels/rx/settings.rx b/eval/reference/labels/rx/settings.rx new file mode 100644 index 0000000..0df8231 --- /dev/null +++ b/eval/reference/labels/rx/settings.rx @@ -0,0 +1,61 @@ +// The settings page: the saved settings, a draft form seeded from them, and saving. + +import pure { loadSettings } from "./api" +import { saveSettings, Settings } from "./api" + +export error NameRequired() => "Display name is required" +export error NameTooLong(count: number) => `Display name must be at most 20 characters, not ${count}` + +fn validName(name: string): string => + name.length == 0 ? throw NameRequired() + : name.length > 20 ? throw NameTooLong(name.length) + : name + +export component SettingsPage() { + source settings: Settings = await loadSettings() + source saveError: string = "" + + action save(next: Settings) { + optimistic settings := next + optimistic saveError := "" + affects settings + try { + let saved = await saveSettings(next) + settings := saved + } catch (e) { + saveError := e.message + } + } + + errored fallback(e) { +

    Could not load settings: {e.message}

    + + } { + loading fallback {

    Loading settings…

    } { + source draftName: string := peek(settings.name) + source draftTheme: string := peek(settings.theme) + source draftDensity: string := peek(settings.density) + nameError: string = (validName(draftName) && "") catch (e: NameRequired | NameTooLong) => e.message +

    Saved: {settings.name} · {settings.theme} · {settings.density}{pending? settings ? " (saving…)" : ""}

    + {if saveError != "" { +

    Save failed: {saveError}

    + }} + {" "} + {draftName.length}/20 + {if nameError != "" { +

    {nameError}

    + }} +
    + {" "} + {" "} + +
    +

    Preview: {draftTheme} / {draftDensity}

    + + } + } +} diff --git a/eval/reference/labels/solid/App.tsx b/eval/reference/labels/solid/App.tsx new file mode 100644 index 0000000..09ad8a8 --- /dev/null +++ b/eval/reference/labels/solid/App.tsx @@ -0,0 +1,25 @@ +import { createSignal, Match, Switch } from "solid-js"; +import IssuesPage from "./IssuesPage"; +import HelpPage from "./HelpPage"; +import SettingsPage from "./SettingsPage"; + +type Page = "issues" | "help" | "settings"; + +// Only the open page is mounted, so leaving one discards its state. +export default function App() { + const [page, setPage] = createSignal("issues"); + return ( +
    + + + + + + +
    + ); +} diff --git a/eval/reference/labels/solid/Comments.tsx b/eval/reference/labels/solid/Comments.tsx new file mode 100644 index 0000000..1518ac3 --- /dev/null +++ b/eval/reference/labels/solid/Comments.tsx @@ -0,0 +1,65 @@ +import { action, createEffect, createMemo, createOptimisticStore, createSignal, Errored, For, Loading, onFlush, refresh, Show } from "solid-js"; +import { addComment, fetchComments } from "./api"; + +const message = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +type Sending = { id: number; author: string; text: string }; + +// The selected issue's comments, and posting one. Posts in flight live in their own store, so changing the selection +// never waits for them, and each shows only under its own issue. +export default function Comments(props: { id: number; user: string }) { + const comments = createMemo(() => fetchComments(props.id)); + const [sending, setSending] = createOptimisticStore([]); + const [postError, setPostError] = createSignal(""); + const [commentInput, setCommentInput] = createSignal(""); + + // a post error belongs to the issue it was made on: selecting another clears it + createEffect(() => props.id, () => { + setPostError(""); + }); + + const post = action(function* (id: number, author: string, text: string) { + try { + setSending(s => { s.push({ id, author, text }); }); + yield addComment(id, author, text); + refresh(comments); + } catch (e) { + setPostError(message(e)); + } + }); + + return ( +
    +
    + loading comments

    }> +

    comments error: {message(e())}

    }> +
      + {c =>
    • {c.author}: {c.text}
    • }
      + s.id === props.id)}> + {s =>
    • {s.author}: {s.text} (sending…)
    • } +
      +
    +
    +
    +
    + setCommentInput(e.currentTarget.value)} /> + + +

    post error: {postError()}

    +
    +
    + ); +} diff --git a/eval/reference/labels/solid/HelpPage.tsx b/eval/reference/labels/solid/HelpPage.tsx new file mode 100644 index 0000000..40226a6 --- /dev/null +++ b/eval/reference/labels/solid/HelpPage.tsx @@ -0,0 +1,82 @@ +import { createMemo, createSignal, Errored, For, isPending, latest, Loading, refresh, Show } from "solid-js"; +import { fetchItem, search } from "./api"; + +const message = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +class TooShort extends Error {} +class ItemNotFound extends Error {} + +export default function HelpPage() { + const [query, setQuery] = createSignal(""); + const [selected, setSelected] = createSignal(null); + + // empty: nothing; one character: too short, nothing asked; otherwise the search + const results = createMemo(() => { + const q = query(); + if (q === "") return []; + if (q.length < 2) throw new TooShort(`too short: ${q}`); + return search(q); + }); + const item = createMemo(async () => { + const id = selected(); + if (id === null) return null; + const it = await fetchItem(id); + if (it === null) throw new ItemNotFound(`item ${id} not found`); + return it; + }); + + return ( +
    + {/* the input shows what was typed even while the search it started is in flight */} + setQuery(e.currentTarget.value)} /> + +
    + (e() instanceof TooShort ?

    {message(e())}

    :

    search failed: {message(e())}

    )}> + searching…

    }> +

    {isPending(() => results()) ? "searching…" : ""}

    +

    + {results().length} results for {latest(query)} +

    +
      + + {r => ( +
    • setSelected(r.id)}> + {r.id === selected() ? "> " : ""} + {r.name} +
    • + )} +
      +
    +
    +
    +
    +
    + pick a result

    }> +
    + loading…

    }> + {/* a fresh region per selection, so a failure shown for one item doesn't stay up while the next loads */} + + {_id => ( + + e() instanceof ItemNotFound ? ( +

    {message(e())}

    + ) : ( + <> +

    failed: {message(e())}

    + + + ) + } + > +

    {item()?.name}

    +

    {item()?.blurb}

    +
    + )} +
    +
    +
    +
    +
    + ); +} diff --git a/eval/reference/labels/solid/IssuesPage.tsx b/eval/reference/labels/solid/IssuesPage.tsx new file mode 100644 index 0000000..cc65fc0 --- /dev/null +++ b/eval/reference/labels/solid/IssuesPage.tsx @@ -0,0 +1,157 @@ +import { action, createOptimistic, createOptimisticStore, createSignal, Errored, For, latest, Loading, onFlush, refresh, Show } from "solid-js"; +import { assign, closeIssue, fetchIssue, fetchIssues, renameIssue, type Issue, type IssueDetail } from "./api"; +import Comments from "./Comments"; +import Labels from "./Labels"; + +const message = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +export default function IssuesPage() { + const [user, setUser] = createSignal("guest"); + const [filter, setFilter] = createSignal<"open" | "closed">("open"); + const [selected, setSelected] = createSignal(null); + const [titleInput, setTitleInput] = createSignal(""); + const [assignError, setAssignError] = createSignal(""); + const [renameError, setRenameError] = createSignal(""); + const [closeError, setCloseError] = createSignal(""); + + // the list for the filter; writes in an action are guesses until it settles + const [issues, setIssues] = createOptimisticStore(() => fetchIssues(filter()), [], { key: "id" }); + const [detail, setDetail] = createOptimistic(async () => { + const id = selected(); + if (id === null) return null; + const d = await fetchIssue(id); + if (d === null) throw new Error(`issue #${id} not found`); + return d; + }); + + const assignTo = action(function* (id: number, who: string) { + try { + setIssues(list => { + const row = list.find(t => t.id === id); + if (row) row.assignee = who; + }); + yield assign(id, who); + refresh(issues); + } catch (e) { + setAssignError(message(e)); + } + }); + + const renameTo = action(function* (id: number, title: string) { + try { + setIssues(list => { + const row = list.find(t => t.id === id); + if (row) row.title = title; + }); + setDetail(d => (d && d.id === id ? { ...d, title } : d)); + yield renameIssue(id, title); + refresh(issues); + refresh(detail); + } catch (e) { + setRenameError(message(e)); + } + }); + + const closeOne = action(function* (id: number) { + try { + setIssues(list => { + const i = list.findIndex(t => t.id === id); + if (i >= 0) list.splice(i, 1); + }); + yield closeIssue(id); + refresh(issues); + } catch (e) { + setCloseError(message(e)); + } + }); + + const select = (id: number) => { + setSelected(id); + setTitleInput(""); + onFlush(() => { + setAssignError(""); + setRenameError(""); + setCloseError(""); + }); + }; + + return ( +
    +
    + +
    +
    + + +
    +
    + loading {latest(filter)} issues

    }> +

    list error: {message(e())}

    }> +
      + + {it => ( +
    • select(it.id)}> + #{it.id} {it.title} ({it.assignee ?? "unassigned"}) +
    • + )} +
      +
    +
    +
    +
    +
    + select an issue

    }> + loading #{latest(selected)}

    }> +

    detail error: {message(e())}

    }> +

    #{detail()?.id} {detail()?.title}

    +

    {detail()?.body}

    +
    + +
    + + +

    {assignError()}

    +
    + setTitleInput(e.currentTarget.value)} /> + + +

    rename error: {renameError()}

    +
    + + +

    close error: {closeError()}

    +
    + + +
    +
    +
    + ); +} diff --git a/eval/reference/labels/solid/Labels.tsx b/eval/reference/labels/solid/Labels.tsx new file mode 100644 index 0000000..5d80003 --- /dev/null +++ b/eval/reference/labels/solid/Labels.tsx @@ -0,0 +1,92 @@ +import { action, createEffect, createMemo, createOptimisticStore, createSignal, Errored, For, Loading, onFlush, refresh, Show } from "solid-js"; +import { addLabel, fetchLabels, removeLabel } from "./api"; + +const message = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +type Change = { id: number; label: string }; + +// The selected issue's labels, adding and removing them. Changes in flight live in their own stores, so changing the +// selection never waits for them, and each shows only under its own issue. +export default function Labels(props: { id: number }) { + const labels = createMemo(() => fetchLabels(props.id)); + const [adding, setAdding] = createOptimisticStore([]); + const [removing, setRemoving] = createOptimisticStore([]); + const [labelError, setLabelError] = createSignal(""); + const [labelInput, setLabelInput] = createSignal(""); + const shown = () => [ + ...labels().filter(l => !removing.some(r => r.id === props.id && r.label === l)), + ...adding.filter(a => a.id === props.id).map(a => a.label), + ]; + + // a label error belongs to the issue it was made on: selecting another clears it + createEffect(() => props.id, () => { + setLabelError(""); + }); + + const add = action(function* (id: number, label: string) { + try { + setAdding(a => { a.push({ id, label }); }); + yield addLabel(id, label); + refresh(labels); + } catch (e) { + setLabelError(message(e)); + } + }); + + const remove = action(function* (id: number, label: string) { + try { + setRemoving(r => { r.push({ id, label }); }); + yield removeLabel(id, label); + refresh(labels); + } catch (e) { + setLabelError(message(e)); + } + }); + + return ( +
    +
    + loading labels

    }> +

    labels error: {message(e())}

    }> +
      + + {l => ( +
    • + {l}{" "} + +
    • + )} +
      +
    +
    +
    +
    + setLabelInput(e.currentTarget.value)} /> + + +

    label error: {labelError()}

    +
    +
    + ); +} diff --git a/eval/reference/labels/solid/SettingsPage.tsx b/eval/reference/labels/solid/SettingsPage.tsx new file mode 100644 index 0000000..5e949d3 --- /dev/null +++ b/eval/reference/labels/solid/SettingsPage.tsx @@ -0,0 +1,92 @@ +import { action, affects, createOptimistic, createSignal, Errored, isPending, Loading, onFlush, refresh, Show, untrack } from "solid-js"; +import { loadSettings, saveSettings, type Density, type Settings, type Theme } from "./api"; + +const message = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +function nameError(name: string) { + if (name.length === 0) return "Display name is required"; + if (name.length > 20) return `Display name must be at most 20 characters, not ${name.length}`; + return ""; +} + +export default function SettingsPage() { + // what the server has; a save shows its settings over it as a guess until it settles + const [saved, setSaved] = createSignal(() => loadSettings()); + const [settings, setSettings] = createOptimistic(() => saved()); + const [saveError, setSaveError] = createSignal(""); + + const save = action(function* (next: Settings) { + setSettings(next); + affects(settings); + try { + setSaved(yield saveSettings(next)); + } catch (e) { + setSaveError(message(e)); + } + }); + + return ( + ( + <> +

    Could not load settings: {message(e())}

    + + + )} + > + Loading settings…

    }> +

    + Saved: {settings().name} · {settings().theme} · {settings().density} + {isPending(() => settings()) ? " (saving…)" : ""} +

    + +

    Save failed: {saveError()}

    +
    + { + onFlush(() => setSaveError("")); + save(s); + }} + /> +
    +
    + ); +} + +// Seeded once from the loaded settings; after that the draft is the user's. +function SettingsForm(props: { initial: Settings; onSave: (s: Settings) => void }) { + const [name, setName] = createSignal(untrack(() => props.initial.name)); + const [theme, setTheme] = createSignal(untrack(() => props.initial.theme)); + const [density, setDensity] = createSignal(untrack(() => props.initial.density)); + const error = () => nameError(name()); + return ( +
    + {" "} + {name().length}/20 + +

    {error()}

    +
    +
    + {" "} + {" "} + +
    +

    + Preview: {theme()} / {density()} +

    + +
    + ); +} diff --git a/eval/reference/related/foldkit/help.ts b/eval/reference/related/foldkit/help.ts new file mode 100644 index 0000000..391eb26 --- /dev/null +++ b/eval/reference/related/foldkit/help.ts @@ -0,0 +1,221 @@ +import { Array, Effect, Option, Schema } from 'effect' +import { Command, Submodel, type Update } from 'foldkit' +import { Html, HtmlBuilder } from 'foldkit/html' +import { defineMessageUnion } from 'foldkit/message' +import { defineTaggedUnion } from 'foldkit/schema' +import { modifyFields } from 'foldkit/struct' + +import * as Api from './api' +import { request } from './request' + +// MODEL + +const Hit = Schema.Struct({ id: Schema.Number, name: Schema.String }) +const Item = Schema.Struct({ id: Schema.Number, name: Schema.String, blurb: Schema.String }) + +// The results on screen stay until a newer query's answer replaces them. +const Shown = Schema.Struct({ query: Schema.String, hits: Schema.Array(Hit) }) + +export const ItemState = defineTaggedUnion({ + Loading: {}, + Loaded: { maybeItem: Schema.Option(Item) }, + Failed: { error: Schema.String }, +}) + +export const Model = Schema.Struct({ + query: Schema.String, + searchSeq: Schema.Number, + isSearching: Schema.Boolean, + maybeShown: Schema.Option(Shown), + maybeSearchError: Schema.Option(Schema.String), + maybeSelected: Schema.Option(Schema.Number), + itemSeq: Schema.Number, + item: ItemState, +}) +export type Model = typeof Model.Type + +// MESSAGE + +export const Message = defineMessageUnion({ + UpdatedQuery: { value: Schema.String }, + SucceededSearch: { seq: Schema.Number, query: Schema.String, hits: Schema.Array(Hit) }, + FailedSearch: { seq: Schema.Number, error: Schema.String }, + ClickedHit: { id: Schema.Number }, + SucceededFetchItem: { seq: Schema.Number, item: Schema.NullOr(Item) }, + FailedFetchItem: { seq: Schema.Number, error: Schema.String }, + ClickedRetryItem: {}, +}) +export type Message = typeof Message.Type + +// COMMAND + +const Search = Command.define('Search', { + args: { seq: Schema.Number, query: Schema.String }, + messages: [Message.SucceededSearch, Message.FailedSearch], + execute: ({ seq, query }) => + request(() => Api.search(query)).pipe( + Effect.map(hits => Message.SucceededSearch({ seq, query, hits })), + Effect.catch(error => Effect.succeed(Message.FailedSearch({ seq, error }))), + ), +}) + +const FetchItem = Command.define('FetchItem', { + args: { seq: Schema.Number, id: Schema.Number }, + messages: [Message.SucceededFetchItem, Message.FailedFetchItem], + execute: ({ seq, id }) => + request(() => Api.fetchItem(id)).pipe( + Effect.map(item => Message.SucceededFetchItem({ seq, item })), + Effect.catch(error => Effect.succeed(Message.FailedFetchItem({ seq, error }))), + ), +}) + +// INIT + +// Opens empty from its tab, or on an issue's title with a related hit selected. +export const init = (query: string, maybeSelected: Option.Option): Update.Return => { + const isAsked = query.length >= 2 + return { + model: { + query, + searchSeq: 1, + isSearching: isAsked, + maybeShown: Option.none(), + maybeSearchError: Option.none(), + maybeSelected, + itemSeq: 1, + item: ItemState.Loading(), + }, + commands: [ + ...(isAsked ? [Search({ seq: 1, query })] : []), + ...Option.match(maybeSelected, { onNone: () => [], onSome: id => [FetchItem({ seq: 1, id })] }), + ], + } +} + +// UPDATE + +export const update = (model: Model, message: Message) => + Message.match>(message, { + UpdatedQuery: ({ value }) => { + const searchSeq = model.searchSeq + 1 + const isAsked = value.length >= 2 + const next = modifyFields(model, { + query: () => value, + searchSeq: () => searchSeq, + isSearching: () => isAsked, + maybeSearchError: () => Option.none(), + maybeShown: shown => (value === '' ? Option.none() : shown), + }) + return isAsked ? { model: next, commands: [Search({ seq: searchSeq, query: value })] } : { model: next } + }, + + SucceededSearch: ({ seq, query, hits }) => + seq === model.searchSeq + ? { + model: modifyFields(model, { + isSearching: () => false, + maybeShown: () => Option.some({ query, hits }), + }), + } + : { model }, + + FailedSearch: ({ seq, error }) => + seq === model.searchSeq + ? { model: modifyFields(model, { isSearching: () => false, maybeSearchError: () => Option.some(error) }) } + : { model }, + + ClickedHit: ({ id }) => { + if (Option.contains(model.maybeSelected, id)) { + return { model } + } + const itemSeq = model.itemSeq + 1 + return { + model: modifyFields(model, { + maybeSelected: () => Option.some(id), + itemSeq: () => itemSeq, + item: () => ItemState.Loading(), + }), + commands: [FetchItem({ seq: itemSeq, id })], + } + }, + + ClickedRetryItem: () => + Option.match(model.maybeSelected, { + onNone: () => ({ model }), + onSome: id => { + const itemSeq = model.itemSeq + 1 + return { model: modifyFields(model, { itemSeq: () => itemSeq }), commands: [FetchItem({ seq: itemSeq, id })] } + }, + }), + + SucceededFetchItem: ({ seq, item }) => + seq === model.itemSeq + ? { model: modifyFields(model, { item: () => ItemState.Loaded({ maybeItem: Option.fromNullishOr(item) }) }) } + : { model }, + + FailedFetchItem: ({ seq, error }) => + seq === model.itemSeq ? { model: modifyFields(model, { item: () => ItemState.Failed({ error }) }) } : { model }, + }) + +// VIEW + +const testId = (h: HtmlBuilder, id: string) => h.DataAttribute('testid', id) + +const resultsView = (model: Model, h: HtmlBuilder): Html => { + if (model.query.length < 2) { + return h.section([testId(h, 'results')], [h.p([], [`too short: ${model.query}`])]) + } + const shown = Option.match(model.maybeSearchError, { + onSome: error => (model.isSearching ? h.empty : h.p([], [`search failed: ${error}`])), + onNone: () => + Option.match(model.maybeShown, { + onNone: () => h.empty, + onSome: ({ query, hits }) => + h.div([], [ + h.h2([], [`${hits.length} results for ${query}`]), + h.ul( + [], + Array.map(hits, hit => + h.keyed('li')( + String(hit.id), + [testId(h, 'hit'), h.OnClick(Message.ClickedHit({ id: hit.id }))], + [`${Option.contains(model.maybeSelected, hit.id) ? '> ' : ''}${hit.name}`], + ), + ), + ), + ]), + }), + }) + return h.section([testId(h, 'results')], [model.isSearching ? h.p([], ['searching…']) : h.empty, shown]) +} + +const itemView = (model: Model, id: number, h: HtmlBuilder): Html => + h.section( + [testId(h, 'item')], + [ + ItemState.match(model.item, { + Loading: () => h.p([], ['loading…']), + Failed: ({ error }) => + h.div([], [ + h.p([], [`failed: ${error}`]), + h.button([testId(h, 'retry-item'), h.OnClick(Message.ClickedRetryItem())], ['retry']), + ]), + Loaded: ({ maybeItem }) => + Option.match(maybeItem, { + onNone: () => h.p([], [`item ${id} not found`]), + onSome: item => h.article([], [h.h3([], [item.name]), h.p([], [item.blurb])]), + }), + }), + ], + ) + +export const view = Submodel.defineView((model, h) => + h.main([], [ + h.input([testId(h, 'query'), h.Value(model.query), h.OnInput(value => Message.UpdatedQuery({ value }))]), + model.query === '' ? h.empty : resultsView(model, h), + Option.match(model.maybeSelected, { + onNone: () => h.p([testId(h, 'item')], ['pick a result']), + onSome: id => itemView(model, id, h), + }), + ]), +) diff --git a/eval/reference/related/foldkit/issues.ts b/eval/reference/related/foldkit/issues.ts new file mode 100644 index 0000000..1a29b04 --- /dev/null +++ b/eval/reference/related/foldkit/issues.ts @@ -0,0 +1,698 @@ +import { Array, Effect, Option, Schema, pipe } from 'effect' +import { Command, Submodel, type Update } from 'foldkit' +import { Html, HtmlBuilder } from 'foldkit/html' +import { defineMessageUnion } from 'foldkit/message' +import { defineTaggedUnion } from 'foldkit/schema' +import { modifyFields } from 'foldkit/struct' + +import * as Api from './api' +import { request } from './request' + +// MODEL + +const Filter = Schema.Literals(['open', 'closed']) +type Filter = typeof Filter.Type + +const Issue = Schema.Struct({ id: Schema.Number, title: Schema.String, assignee: Schema.NullOr(Schema.String) }) +type Issue = typeof Issue.Type +const Detail = Schema.Struct({ id: Schema.Number, title: Schema.String, body: Schema.String }) +const Comment = Schema.Struct({ author: Schema.String, text: Schema.String }) + +export const ListState = defineTaggedUnion({ + Loading: {}, + Loaded: { issues: Schema.Array(Issue) }, + Failed: { error: Schema.String }, +}) +export const DetailState = defineTaggedUnion({ + Loading: {}, + Loaded: { detail: Detail }, + Failed: { error: Schema.String }, +}) +export const CommentsState = defineTaggedUnion({ + Loading: {}, + Loaded: { comments: Schema.Array(Comment) }, + Failed: { error: Schema.String }, +}) + +const Hit = Schema.Struct({ id: Schema.Number, name: Schema.String }) + +export const RelatedState = defineTaggedUnion({ + Loading: {}, + Loaded: { hits: Schema.Array(Hit) }, + Failed: { error: Schema.String }, +}) + +// A write still in the air. `seq` orders writes: the newest guess for an issue is what it shows. +const Guess = Schema.Struct({ seq: Schema.Number, id: Schema.Number, value: Schema.String }) +type Guess = typeof Guess.Type +const Sending = Schema.Struct({ seq: Schema.Number, id: Schema.Number, author: Schema.String, text: Schema.String }) +// An error that belongs to one issue: shown only while that issue is selected. +const IssueError = Schema.Struct({ id: Schema.Number, message: Schema.String }) +type IssueError = typeof IssueError.Type +// The newest write of a kind that the server confirmed, per issue. +const Confirmed = Schema.Struct({ id: Schema.Number, seq: Schema.Number }) +type Confirmed = typeof Confirmed.Type + +export const Model = Schema.Struct({ + user: Schema.String, + filter: Filter, + listSeq: Schema.Number, + list: ListState, + maybeSelected: Schema.Option(Schema.Number), + detailSeq: Schema.Number, + detail: DetailState, + comments: CommentsState, + seq: Schema.Number, + assigning: Schema.Array(Guess), + confirmedAssigns: Schema.Array(Confirmed), + lastAssign: Schema.Number, + maybeAssignError: Schema.Option(IssueError), + titleInput: Schema.String, + renaming: Schema.Array(Guess), + confirmedRenames: Schema.Array(Confirmed), + lastRename: Schema.Number, + maybeRenameError: Schema.Option(IssueError), + closing: Schema.Array(Schema.Struct({ seq: Schema.Number, id: Schema.Number })), + lastClose: Schema.Number, + maybeCloseError: Schema.Option(IssueError), + commentInput: Schema.String, + sending: Schema.Array(Sending), + lastPost: Schema.Number, + maybePostError: Schema.Option(IssueError), + maybeRelatedTitle: Schema.Option(Schema.String), + relatedSeq: Schema.Number, + related: RelatedState, +}) +export type Model = typeof Model.Type + +// MESSAGE + +export const Message = defineMessageUnion({ + UpdatedUser: { value: Schema.String }, + ClickedFilter: { filter: Filter }, + SucceededFetchIssues: { seq: Schema.Number, issues: Schema.Array(Issue) }, + FailedFetchIssues: { seq: Schema.Number, error: Schema.String }, + ClickedIssue: { id: Schema.Number }, + ClickedRetry: {}, + SucceededFetchIssue: { seq: Schema.Number, detail: Schema.NullOr(Detail) }, + FailedFetchIssue: { seq: Schema.Number, error: Schema.String }, + SucceededFetchComments: { seq: Schema.Number, comments: Schema.Array(Comment) }, + FailedFetchComments: { seq: Schema.Number, error: Schema.String }, + ClickedAssign: {}, + SucceededAssign: { seq: Schema.Number, id: Schema.Number, assignee: Schema.String }, + FailedAssign: { seq: Schema.Number, id: Schema.Number, error: Schema.String }, + UpdatedTitleInput: { value: Schema.String }, + ClickedRename: {}, + SucceededRename: { seq: Schema.Number, id: Schema.Number, title: Schema.String }, + FailedRename: { seq: Schema.Number, id: Schema.Number, error: Schema.String }, + ClickedClose: {}, + SucceededClose: { seq: Schema.Number, id: Schema.Number }, + FailedClose: { seq: Schema.Number, id: Schema.Number, error: Schema.String }, + UpdatedCommentInput: { value: Schema.String }, + ClickedPost: {}, + SucceededPost: { seq: Schema.Number, id: Schema.Number, comment: Comment }, + FailedPost: { seq: Schema.Number, id: Schema.Number, error: Schema.String }, + SucceededSearchRelated: { seq: Schema.Number, hits: Schema.Array(Hit) }, + FailedSearchRelated: { seq: Schema.Number, error: Schema.String }, + ClickedRelatedHit: { query: Schema.String, id: Schema.Number }, +}) +export type Message = typeof Message.Type + +// COMMAND + +const FetchIssues = Command.define('FetchIssues', { + args: { seq: Schema.Number, filter: Filter }, + messages: [Message.SucceededFetchIssues, Message.FailedFetchIssues], + execute: ({ seq, filter }) => + request(() => Api.fetchIssues(filter)).pipe( + Effect.map(issues => Message.SucceededFetchIssues({ seq, issues })), + Effect.catch(error => Effect.succeed(Message.FailedFetchIssues({ seq, error }))), + ), +}) + +const FetchIssue = Command.define('FetchIssue', { + args: { seq: Schema.Number, id: Schema.Number }, + messages: [Message.SucceededFetchIssue, Message.FailedFetchIssue], + execute: ({ seq, id }) => + request(() => Api.fetchIssue(id)).pipe( + Effect.map(detail => Message.SucceededFetchIssue({ seq, detail })), + Effect.catch(error => Effect.succeed(Message.FailedFetchIssue({ seq, error }))), + ), +}) + +const FetchComments = Command.define('FetchComments', { + args: { seq: Schema.Number, id: Schema.Number }, + messages: [Message.SucceededFetchComments, Message.FailedFetchComments], + execute: ({ seq, id }) => + request(() => Api.fetchComments(id)).pipe( + Effect.map(comments => Message.SucceededFetchComments({ seq, comments })), + Effect.catch(error => Effect.succeed(Message.FailedFetchComments({ seq, error }))), + ), +}) + +const Assign = Command.define('Assign', { + args: { seq: Schema.Number, id: Schema.Number, user: Schema.String }, + messages: [Message.SucceededAssign, Message.FailedAssign], + execute: ({ seq, id, user }) => + request(() => Api.assign(id, user)).pipe( + Effect.map(answer => Message.SucceededAssign({ seq, id, assignee: answer.assignee })), + Effect.catch(error => Effect.succeed(Message.FailedAssign({ seq, id, error }))), + ), +}) + +const Rename = Command.define('Rename', { + args: { seq: Schema.Number, id: Schema.Number, title: Schema.String }, + messages: [Message.SucceededRename, Message.FailedRename], + execute: ({ seq, id, title }) => + request(() => Api.renameIssue(id, title)).pipe( + Effect.map(answer => Message.SucceededRename({ seq, id, title: answer.title })), + Effect.catch(error => Effect.succeed(Message.FailedRename({ seq, id, error }))), + ), +}) + +const Close = Command.define('Close', { + args: { seq: Schema.Number, id: Schema.Number }, + messages: [Message.SucceededClose, Message.FailedClose], + execute: ({ seq, id }) => + request(() => Api.closeIssue(id)).pipe( + Effect.as(Message.SucceededClose({ seq, id })), + Effect.catch(error => Effect.succeed(Message.FailedClose({ seq, id, error }))), + ), +}) + +const Post = Command.define('Post', { + args: { seq: Schema.Number, id: Schema.Number, author: Schema.String, text: Schema.String }, + messages: [Message.SucceededPost, Message.FailedPost], + execute: ({ seq, id, author, text }) => + request(() => Api.addComment(id, author, text)).pipe( + Effect.map(comment => Message.SucceededPost({ seq, id, comment })), + Effect.catch(error => Effect.succeed(Message.FailedPost({ seq, id, error }))), + ), +}) + +const SearchRelated = Command.define('SearchRelated', { + args: { seq: Schema.Number, title: Schema.String }, + messages: [Message.SucceededSearchRelated, Message.FailedSearchRelated], + execute: ({ seq, title }) => + request(() => Api.search(title)).pipe( + Effect.map(hits => Message.SucceededSearchRelated({ seq, hits })), + Effect.catch(error => Effect.succeed(Message.FailedSearchRelated({ seq, error }))), + ), +}) + +// INIT + +export const init = (): Update.Return => ({ + model: { + user: 'guest', + filter: 'open', + listSeq: 1, + list: ListState.Loading(), + maybeSelected: Option.none(), + detailSeq: 0, + detail: DetailState.Loading(), + comments: CommentsState.Loading(), + seq: 1, + assigning: [], + confirmedAssigns: [], + lastAssign: 0, + maybeAssignError: Option.none(), + titleInput: '', + renaming: [], + confirmedRenames: [], + lastRename: 0, + maybeRenameError: Option.none(), + closing: [], + lastClose: 0, + maybeCloseError: Option.none(), + commentInput: '', + sending: [], + lastPost: 0, + maybePostError: Option.none(), + maybeRelatedTitle: Option.none(), + relatedSeq: 0, + related: RelatedState.Loading(), + }, + commands: [FetchIssues({ seq: 1, filter: 'open' })], +}) + +// UPDATE + +const dropGuess = + (seq: number) => +
    (guesses: ReadonlyArray): ReadonlyArray => + Array.filter(guesses, guess => guess.seq !== seq) + +const latestGuess = (guesses: ReadonlyArray, id: number): Option.Option => + pipe( + guesses, + Array.filter(guess => guess.id === id), + Array.last, + Option.map(guess => guess.value), + ) + +const confirmedSeq = (confirmed: ReadonlyArray, id: number): number => + pipe( + confirmed, + Array.findFirst(entry => entry.id === id), + Option.match({ onNone: () => 0, onSome: entry => entry.seq }), + ) + +const confirm = (confirmed: ReadonlyArray, id: number, seq: number): ReadonlyArray => [ + ...Array.filter(confirmed, entry => entry.id !== id), + { id, seq }, +] + +const mapIssue = (id: number, f: (issue: Issue) => Issue) => (list: typeof ListState.Type) => + ListState.match(list, { + Loading: () => list, + Failed: () => list, + Loaded: ({ issues }) => ListState.Loaded({ issues: Array.map(issues, issue => (issue.id === id ? f(issue) : issue)) }), + }) + +const isSelected = (model: Model, id: number) => Option.contains(model.maybeSelected, id) + +// Only the newest write of a kind reports its failure. +const failure = (last: number, seq: number, id: number, message: string) => (current: Option.Option) => + last === seq ? Option.some({ id, message }) : current + +const shownTitle = (model: Model): Option.Option => + DetailState.match(model.detail, { + Loading: () => Option.none(), + Failed: () => Option.none(), + Loaded: ({ detail }) => Option.some(Option.getOrElse(latestGuess(model.renaming, detail.id), () => detail.title)), + }) + +// Related help follows the title the detail shows: whenever that changes, it is searched again. +const withRelated = (result: Update.Return): Update.Return => { + const maybeTitle = shownTitle(result.model) + const isSame = Option.match(maybeTitle, { + onNone: () => Option.isNone(result.model.maybeRelatedTitle), + onSome: title => Option.contains(result.model.maybeRelatedTitle, title), + }) + if (isSame) { + return result + } + const relatedSeq = result.model.relatedSeq + 1 + const model = modifyFields(result.model, { + maybeRelatedTitle: () => maybeTitle, + relatedSeq: () => relatedSeq, + related: () => RelatedState.Loading(), + }) + return Option.match(maybeTitle, { + onNone: () => ({ ...result, model }), + onSome: title => ({ model, commands: [...(result.commands ?? []), SearchRelated({ seq: relatedSeq, title })] }), + }) +} + +export const update = (model: Model, message: Message) => withRelated(updateIssues(model, message)) + +const updateIssues = (model: Model, message: Message) => + Message.match>(message, { + UpdatedUser: ({ value }) => ({ model: modifyFields(model, { user: () => value }) }), + + ClickedFilter: ({ filter }) => { + if (filter === model.filter) { + return { model } + } + const listSeq = model.listSeq + 1 + return { + model: modifyFields(model, { filter: () => filter, listSeq: () => listSeq, list: () => ListState.Loading() }), + commands: [FetchIssues({ seq: listSeq, filter })], + } + }, + + SucceededFetchIssues: ({ seq, issues }) => + seq === model.listSeq ? { model: modifyFields(model, { list: () => ListState.Loaded({ issues }) }) } : { model }, + + FailedFetchIssues: ({ seq, error }) => + seq === model.listSeq ? { model: modifyFields(model, { list: () => ListState.Failed({ error }) }) } : { model }, + + ClickedIssue: ({ id }) => { + const cleared = modifyFields(model, { + titleInput: () => '', + maybeAssignError: () => Option.none(), + maybeRenameError: () => Option.none(), + maybeCloseError: () => Option.none(), + maybePostError: () => Option.none(), + }) + if (isSelected(model, id)) { + return { model: cleared } + } + const detailSeq = model.detailSeq + 1 + return { + model: modifyFields(cleared, { + maybeSelected: () => Option.some(id), + detailSeq: () => detailSeq, + detail: () => DetailState.Loading(), + comments: () => CommentsState.Loading(), + }), + commands: [FetchIssue({ seq: detailSeq, id }), FetchComments({ seq: detailSeq, id })], + } + }, + + ClickedRetry: () => + Option.match(model.maybeSelected, { + onNone: () => ({ model }), + onSome: id => { + const detailSeq = model.detailSeq + 1 + return { + model: modifyFields(model, { detailSeq: () => detailSeq, detail: () => DetailState.Loading() }), + commands: [FetchIssue({ seq: detailSeq, id })], + } + }, + }), + + SucceededFetchIssue: ({ seq, detail }) => { + if (seq !== model.detailSeq) { + return { model } + } + const id = Option.getOrElse(model.maybeSelected, () => 0) + return { + model: modifyFields(model, { + detail: () => + detail === null ? DetailState.Failed({ error: `issue #${id} not found` }) : DetailState.Loaded({ detail }), + }), + } + }, + + FailedFetchIssue: ({ seq, error }) => + seq === model.detailSeq ? { model: modifyFields(model, { detail: () => DetailState.Failed({ error }) }) } : { model }, + + SucceededFetchComments: ({ seq, comments }) => + seq === model.detailSeq + ? { model: modifyFields(model, { comments: () => CommentsState.Loaded({ comments }) }) } + : { model }, + + FailedFetchComments: ({ seq, error }) => + seq === model.detailSeq ? { model: modifyFields(model, { comments: () => CommentsState.Failed({ error }) }) } : { model }, + + ClickedAssign: () => + Option.match(model.maybeSelected, { + onNone: () => ({ model }), + onSome: id => ({ + model: modifyFields(model, { + seq: seq => seq + 1, + lastAssign: () => model.seq, + maybeAssignError: () => Option.none(), + assigning: Array.append({ seq: model.seq, id, value: model.user }), + }), + commands: [Assign({ seq: model.seq, id, user: model.user })], + }), + }), + + SucceededAssign: ({ seq, id, assignee }) => { + const isNewest = confirmedSeq(model.confirmedAssigns, id) < seq + return { + model: modifyFields(model, { + assigning: dropGuess(seq), + confirmedAssigns: confirmed => (isNewest ? confirm(confirmed, id, seq) : confirmed), + list: list => (isNewest ? mapIssue(id, issue => ({ ...issue, assignee }))(list) : list), + }), + } + }, + + FailedAssign: ({ seq, id, error }) => ({ + model: modifyFields(model, { + assigning: dropGuess(seq), + maybeAssignError: failure(model.lastAssign, seq, id, error), + }), + }), + + UpdatedTitleInput: ({ value }) => ({ model: modifyFields(model, { titleInput: () => value }) }), + + ClickedRename: () => + Option.match(model.maybeSelected, { + onNone: () => ({ model }), + onSome: id => + model.titleInput === '' + ? { model } + : { + model: modifyFields(model, { + seq: seq => seq + 1, + lastRename: () => model.seq, + maybeRenameError: () => Option.none(), + renaming: Array.append({ seq: model.seq, id, value: model.titleInput }), + }), + commands: [Rename({ seq: model.seq, id, title: model.titleInput })], + }, + }), + + SucceededRename: ({ seq, id, title }) => { + const isNewest = confirmedSeq(model.confirmedRenames, id) < seq + return { + model: modifyFields(model, { + renaming: dropGuess(seq), + confirmedRenames: confirmed => (isNewest ? confirm(confirmed, id, seq) : confirmed), + list: list => (isNewest ? mapIssue(id, issue => ({ ...issue, title }))(list) : list), + detail: detail => + isNewest && detail._tag === 'Loaded' && detail.detail.id === id + ? DetailState.Loaded({ detail: { ...detail.detail, title } }) + : detail, + }), + } + }, + + FailedRename: ({ seq, id, error }) => ({ + model: modifyFields(model, { + renaming: dropGuess(seq), + maybeRenameError: failure(model.lastRename, seq, id, error), + }), + }), + + ClickedClose: () => + Option.match(model.maybeSelected, { + onNone: () => ({ model }), + onSome: id => ({ + model: modifyFields(model, { + seq: seq => seq + 1, + lastClose: () => model.seq, + maybeCloseError: () => Option.none(), + closing: Array.append({ seq: model.seq, id }), + }), + commands: [Close({ seq: model.seq, id })], + }), + }), + + SucceededClose: ({ seq, id }) => ({ + model: modifyFields(model, { + closing: dropGuess(seq), + list: list => + model.filter === 'open' && list._tag === 'Loaded' + ? ListState.Loaded({ issues: Array.filter(list.issues, issue => issue.id !== id) }) + : list, + }), + }), + + FailedClose: ({ seq, id, error }) => ({ + model: modifyFields(model, { + closing: dropGuess(seq), + maybeCloseError: failure(model.lastClose, seq, id, error), + }), + }), + + UpdatedCommentInput: ({ value }) => ({ model: modifyFields(model, { commentInput: () => value }) }), + + ClickedPost: () => + Option.match(model.maybeSelected, { + onNone: () => ({ model }), + onSome: id => + model.commentInput === '' + ? { model } + : { + model: modifyFields(model, { + seq: seq => seq + 1, + lastPost: () => model.seq, + maybePostError: () => Option.none(), + commentInput: () => '', + sending: Array.append({ seq: model.seq, id, author: model.user, text: model.commentInput }), + }), + commands: [Post({ seq: model.seq, id, author: model.user, text: model.commentInput })], + }, + }), + + SucceededPost: ({ seq, id, comment }) => ({ + model: modifyFields(model, { + sending: dropGuess(seq), + comments: comments => + isSelected(model, id) && comments._tag === 'Loaded' + ? CommentsState.Loaded({ comments: Array.append(comments.comments, comment) }) + : comments, + }), + }), + + FailedPost: ({ seq, id, error }) => ({ + model: modifyFields(model, { + sending: dropGuess(seq), + maybePostError: failure(model.lastPost, seq, id, error), + }), + }), + + SucceededSearchRelated: ({ seq, hits }) => + seq === model.relatedSeq ? { model: modifyFields(model, { related: () => RelatedState.Loaded({ hits }) }) } : { model }, + + FailedSearchRelated: ({ seq, error }) => + seq === model.relatedSeq ? { model: modifyFields(model, { related: () => RelatedState.Failed({ error }) }) } : { model }, + + ClickedRelatedHit: () => ({ model }), + }) + +// VIEW + +const testId = (h: HtmlBuilder, id: string) => h.DataAttribute('testid', id) + +const shownIssues = (model: Model, issues: ReadonlyArray): ReadonlyArray => + pipe( + issues, + Array.filter(issue => !(model.filter === 'open' && Array.some(model.closing, closing => closing.id === issue.id))), + Array.map(issue => ({ + ...issue, + title: Option.getOrElse(latestGuess(model.renaming, issue.id), () => issue.title), + assignee: Option.getOrElse(latestGuess(model.assigning, issue.id), () => issue.assignee), + })), + ) + +const listView = (model: Model, h: HtmlBuilder): Html => + ListState.match(model.list, { + Loading: () => h.p([], [`loading ${model.filter} issues`]), + Failed: ({ error }) => h.p([], [`list error: ${error}`]), + Loaded: ({ issues }) => + h.ul( + [], + Array.map(shownIssues(model, issues), issue => + h.keyed('li')(String(issue.id), [], [ + h.button( + [testId(h, 'issue'), h.OnClick(Message.ClickedIssue({ id: issue.id }))], + [`#${issue.id} ${issue.title} (${issue.assignee ?? 'unassigned'})`], + ), + ]), + ), + ), + }) + +const errorLine = (model: Model, maybeError: Option.Option, prefix: string, h: HtmlBuilder): Html => + pipe( + maybeError, + Option.filter(error => isSelected(model, error.id)), + Option.match({ onNone: () => h.empty, onSome: error => h.p([], [`${prefix}${error.message}`]) }), + ) + +const assignErrorLine = (model: Model, h: HtmlBuilder): Html => + pipe( + model.maybeAssignError, + Option.filter(error => isSelected(model, error.id)), + Option.match({ onNone: () => h.empty, onSome: error => h.p([testId(h, 'assign-error')], [error.message]) }), + ) + +const commentsView = (model: Model, id: number, h: HtmlBuilder): Html => + CommentsState.match(model.comments, { + Loading: () => h.p([], ['loading comments']), + Failed: ({ error }) => h.p([], [`comments error: ${error}`]), + Loaded: ({ comments }) => + h.ul([], [ + ...Array.map(comments, comment => h.li([testId(h, 'comment')], [`${comment.author}: ${comment.text}`])), + ...pipe( + model.sending, + Array.filter(sending => sending.id === id), + Array.map(sending => + h.keyed('li')(`s${sending.seq}`, [testId(h, 'comment')], [`${sending.author}: ${sending.text} (sending…)`]), + ), + ), + ]), + }) + +const relatedView = (model: Model, h: HtmlBuilder): Html => + h.div( + [testId(h, 'related')], + [ + RelatedState.match(model.related, { + Loading: () => h.p([], ['finding related help']), + Failed: ({ error }) => h.p([], [`related help failed: ${error}`]), + Loaded: ({ hits }) => + Array.match(hits, { + onEmpty: () => h.p([], ['no related help']), + onNonEmpty: nonEmpty => + h.ul( + [], + Array.map(Array.take(nonEmpty, 3), hit => + h.keyed('li')(String(hit.id), [], [ + h.button( + [ + testId(h, 'related-hit'), + h.OnClick( + Message.ClickedRelatedHit({ + query: Option.getOrElse(model.maybeRelatedTitle, () => ''), + id: hit.id, + }), + ), + ], + [hit.name], + ), + ]), + ), + ), + }), + }), + ], + ) + +const detailBody = (model: Model, id: number, h: HtmlBuilder): Html => + DetailState.match(model.detail, { + Loading: () => h.p([], [`loading #${id}`]), + Failed: ({ error }) => h.p([], [`detail error: ${error}`]), + Loaded: ({ detail }) => + h.div([], [ + h.h2([], [`#${detail.id} ${Option.getOrElse(latestGuess(model.renaming, detail.id), () => detail.title)}`]), + h.p([], [detail.body]), + relatedView(model, h), + ]), + }) + +const detailView = (model: Model, id: number, h: HtmlBuilder): ReadonlyArray => [ + detailBody(model, id, h), + h.button([testId(h, 'retry'), h.OnClick(Message.ClickedRetry())], ['retry']), + h.button([testId(h, 'assign'), h.OnClick(Message.ClickedAssign())], [`assign to ${model.user}`]), + assignErrorLine(model, h), + h.div([], [ + h.input([ + testId(h, 'title-input'), + h.Value(model.titleInput), + h.OnInput(value => Message.UpdatedTitleInput({ value })), + ]), + h.button([testId(h, 'rename'), h.OnClick(Message.ClickedRename())], ['rename']), + errorLine(model, model.maybeRenameError, 'rename error: ', h), + ]), + h.button([testId(h, 'close'), h.OnClick(Message.ClickedClose())], ['close']), + errorLine(model, model.maybeCloseError, 'close error: ', h), + h.div([testId(h, 'comments')], [commentsView(model, id, h)]), + h.input([ + testId(h, 'comment-input'), + h.Value(model.commentInput), + h.OnInput(value => Message.UpdatedCommentInput({ value })), + ]), + h.button([testId(h, 'post'), h.OnClick(Message.ClickedPost())], ['post']), + errorLine(model, model.maybePostError, 'post error: ', h), +] + +export const view = Submodel.defineView((model, h) => + h.div([], [ + h.section([], [ + h.label([], [ + 'user ', + h.input([testId(h, 'user'), h.Value(model.user), h.OnInput(value => Message.UpdatedUser({ value }))]), + ]), + h.div([], [ + h.button([testId(h, 'filter-open'), h.OnClick(Message.ClickedFilter({ filter: 'open' }))], ['open']), + h.button([testId(h, 'filter-closed'), h.OnClick(Message.ClickedFilter({ filter: 'closed' }))], ['closed']), + ]), + h.div([testId(h, 'list')], [listView(model, h)]), + ]), + h.section( + [testId(h, 'detail')], + Option.match(model.maybeSelected, { + onNone: () => [h.p([], ['select an issue'])], + onSome: id => detailView(model, id, h), + }), + ), + ]), +) diff --git a/eval/reference/related/foldkit/main.ts b/eval/reference/related/foldkit/main.ts new file mode 100644 index 0000000..a6196fb --- /dev/null +++ b/eval/reference/related/foldkit/main.ts @@ -0,0 +1,135 @@ +import { Option, Schema } from 'effect' +import { Command, type Runtime, type Update } from 'foldkit' +import { Document, Html, HtmlBuilder } from 'foldkit/html' +import { defineMessageUnion } from 'foldkit/message' +import { defineTaggedUnion } from 'foldkit/schema' + +import * as Help from './help' +import * as Issues from './issues' +import * as Settings from './settings' + +// MODEL + +const Tab = Schema.Literals(['issues', 'help', 'settings']) +type Tab = typeof Tab.Type + +// Each opening of a page gets a new `pageId`. Answers to a page that was left carry an old one and are dropped. +export const Page = defineTaggedUnion({ + Issues: { model: Issues.Model }, + Help: { model: Help.Model }, + Settings: { model: Settings.Model }, +}) + +export const Model = Schema.Struct({ pageId: Schema.Number, page: Page }) +export type Model = typeof Model.Type + +// MESSAGE + +export const Message = defineMessageUnion({ + ClickedTab: { tab: Tab }, + GotIssuesMessage: { pageId: Schema.Number, message: Issues.Message }, + GotHelpMessage: { pageId: Schema.Number, message: Help.Message }, + GotSettingsMessage: { pageId: Schema.Number, message: Settings.Message }, +}) +export type Message = typeof Message.Type + +// INIT + +const openIssues = (pageId: number, result: Update.Return): Update.Return => ({ + model: { pageId, page: Page.Issues({ model: result.model }) }, + commands: Command.mapMessages(result.commands, message => Message.GotIssuesMessage({ pageId, message })), +}) + +const openHelp = (pageId: number, result: Update.Return): Update.Return => ({ + model: { pageId, page: Page.Help({ model: result.model }) }, + commands: Command.mapMessages(result.commands, message => Message.GotHelpMessage({ pageId, message })), +}) + +const openSettings = ( + pageId: number, + result: Update.Return, +): Update.Return => ({ + model: { pageId, page: Page.Settings({ model: result.model }) }, + commands: Command.mapMessages(result.commands, message => Message.GotSettingsMessage({ pageId, message })), +}) + +export const init: Runtime.ApplicationInit = () => openIssues(1, Issues.init()) + +// UPDATE + +const tabOf = (page: typeof Page.Type): Tab => + Page.match(page, { Issues: () => 'issues', Help: () => 'help', Settings: () => 'settings' }) + +export const update = (model: Model, message: Message) => + Message.match>(message, { + ClickedTab: ({ tab }) => { + if (tab === tabOf(model.page) && tab !== 'help') { + return { model } + } + const pageId = model.pageId + 1 + return tab === 'issues' + ? openIssues(pageId, Issues.init()) + : tab === 'help' + ? openHelp(pageId, Help.init('', Option.none())) + : openSettings(pageId, Settings.init()) + }, + + GotIssuesMessage: ({ pageId, message }) => { + if (model.page._tag !== 'Issues' || pageId !== model.pageId) { + return { model } + } + if (message._tag === 'ClickedRelatedHit') { + return openHelp(pageId + 1, Help.init(message.query, Option.some(message.id))) + } + return openIssues(pageId, Issues.update(model.page.model, message)) + }, + + GotHelpMessage: ({ pageId, message }) => + model.page._tag === 'Help' && pageId === model.pageId + ? openHelp(pageId, Help.update(model.page.model, message)) + : { model }, + + GotSettingsMessage: ({ pageId, message }) => + model.page._tag === 'Settings' && pageId === model.pageId + ? openSettings(pageId, Settings.update(model.page.model, message)) + : { model }, + }) + +// VIEW + +const pageView = (model: Model, h: HtmlBuilder): Html => + Page.match(model.page, { + Issues: page => + h.submodel({ + slotId: `issues-${model.pageId}`, + model: page.model, + view: Issues.view, + toParentMessage: message => Message.GotIssuesMessage({ pageId: model.pageId, message }), + }), + Help: page => + h.submodel({ + slotId: `help-${model.pageId}`, + model: page.model, + view: Help.view, + toParentMessage: message => Message.GotHelpMessage({ pageId: model.pageId, message }), + }), + Settings: page => + h.submodel({ + slotId: `settings-${model.pageId}`, + model: page.model, + view: Settings.view, + toParentMessage: message => Message.GotSettingsMessage({ pageId: model.pageId, message }), + }), + }) + +export const view = (model: Model, h: HtmlBuilder): Document => ({ + title: 'Issue desk', + body: h.div([], [ + h.nav([], [ + h.button([h.DataAttribute('testid', 'tab-issues'), h.OnClick(Message.ClickedTab({ tab: 'issues' }))], ['Issues']), + h.button([h.DataAttribute('testid', 'tab-help'), h.OnClick(Message.ClickedTab({ tab: 'help' }))], ['Help']), + h.button([h.DataAttribute('testid', 'tab-settings'), h.OnClick(Message.ClickedTab({ tab: 'settings' }))], ['Settings']), + ]), + pageView(model, h), + ]), +}) diff --git a/eval/reference/related/foldkit/request.ts b/eval/reference/related/foldkit/request.ts new file mode 100644 index 0000000..d744b31 --- /dev/null +++ b/eval/reference/related/foldkit/request.ts @@ -0,0 +1,7 @@ +import { Effect } from 'effect' + +export const errorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error) + +export const request = (call: () => Promise): Effect.Effect => + Effect.tryPromise({ try: call, catch: errorMessage }) diff --git a/eval/reference/related/foldkit/settings.ts b/eval/reference/related/foldkit/settings.ts new file mode 100644 index 0000000..01f5807 --- /dev/null +++ b/eval/reference/related/foldkit/settings.ts @@ -0,0 +1,201 @@ +import { Array, Effect, Option, Schema, pipe } from 'effect' +import { Command, Submodel, type Update } from 'foldkit' +import { Html, HtmlBuilder } from 'foldkit/html' +import { defineMessageUnion } from 'foldkit/message' +import { defineTaggedUnion } from 'foldkit/schema' +import { modifyFields } from 'foldkit/struct' + +import * as Api from './api' +import { request } from './request' + +// MODEL + +const Theme = Schema.Literals(['light', 'dark']) +const Density = Schema.Literals(['comfortable', 'compact']) +const Settings = Schema.Struct({ name: Schema.String, theme: Theme, density: Density }) +type Settings = typeof Settings.Type + +// The draft is seeded once from the loaded settings; after that it is the user's. +export const LoadState = defineTaggedUnion({ + Loading: {}, + Failed: { error: Schema.String }, + Loaded: { saved: Settings, draft: Settings }, +}) + +export const Model = Schema.Struct({ + loadSeq: Schema.Number, + load: LoadState, + seq: Schema.Number, + saving: Schema.Array(Schema.Struct({ seq: Schema.Number, value: Settings })), + confirmed: Schema.Number, + maybeSaveError: Schema.Option(Schema.String), +}) +export type Model = typeof Model.Type + +// MESSAGE + +export const Message = defineMessageUnion({ + SucceededLoad: { seq: Schema.Number, settings: Settings }, + FailedLoad: { seq: Schema.Number, error: Schema.String }, + ClickedRetryLoad: {}, + UpdatedName: { value: Schema.String }, + ClickedTheme: { theme: Theme }, + ClickedDensityToggle: {}, + ClickedSave: {}, + SucceededSave: { seq: Schema.Number, settings: Settings }, + FailedSave: { seq: Schema.Number, error: Schema.String }, +}) +export type Message = typeof Message.Type + +// COMMAND + +const Load = Command.define('Load', { + args: { seq: Schema.Number }, + messages: [Message.SucceededLoad, Message.FailedLoad], + execute: ({ seq }) => + request(() => Api.loadSettings()).pipe( + Effect.map(settings => Message.SucceededLoad({ seq, settings })), + Effect.catch(error => Effect.succeed(Message.FailedLoad({ seq, error }))), + ), +}) + +const Save = Command.define('Save', { + args: { seq: Schema.Number, settings: Settings }, + messages: [Message.SucceededSave, Message.FailedSave], + execute: ({ seq, settings }) => + request(() => Api.saveSettings(settings)).pipe( + Effect.map(saved => Message.SucceededSave({ seq, settings: saved })), + Effect.catch(error => Effect.succeed(Message.FailedSave({ seq, error }))), + ), +}) + +// INIT + +export const init = (): Update.Return => ({ + model: { loadSeq: 1, load: LoadState.Loading(), seq: 1, saving: [], confirmed: 0, maybeSaveError: Option.none() }, + commands: [Load({ seq: 1 })], +}) + +// UPDATE + +const nameError = (name: string): string => + name.length === 0 + ? 'Display name is required' + : name.length > 20 + ? `Display name must be at most 20 characters, not ${name.length}` + : '' + +const editDraft = (model: Model, f: (draft: Settings) => Settings): Model => + modifyFields(model, { + load: load => (load._tag === 'Loaded' ? LoadState.Loaded({ saved: load.saved, draft: f(load.draft) }) : load), + }) + +export const update = (model: Model, message: Message) => + Message.match>(message, { + SucceededLoad: ({ seq, settings }) => + seq === model.loadSeq + ? { model: modifyFields(model, { load: () => LoadState.Loaded({ saved: settings, draft: settings }) }) } + : { model }, + + FailedLoad: ({ seq, error }) => + seq === model.loadSeq ? { model: modifyFields(model, { load: () => LoadState.Failed({ error }) }) } : { model }, + + ClickedRetryLoad: () => { + const loadSeq = model.loadSeq + 1 + return { model: modifyFields(model, { loadSeq: () => loadSeq }), commands: [Load({ seq: loadSeq })] } + }, + + UpdatedName: ({ value }) => ({ model: editDraft(model, draft => ({ ...draft, name: value })) }), + + ClickedTheme: ({ theme }) => ({ model: editDraft(model, draft => ({ ...draft, theme })) }), + + ClickedDensityToggle: () => ({ + model: editDraft(model, draft => ({ ...draft, density: draft.density === 'compact' ? 'comfortable' : 'compact' })), + }), + + ClickedSave: () => { + if (model.load._tag !== 'Loaded' || nameError(model.load.draft.name) !== '') { + return { model } + } + const draft = model.load.draft + return { + model: modifyFields(model, { + seq: seq => seq + 1, + maybeSaveError: () => Option.none(), + saving: Array.append({ seq: model.seq, value: draft }), + }), + commands: [Save({ seq: model.seq, settings: draft })], + } + }, + + SucceededSave: ({ seq, settings }) => { + const isNewest = seq > model.confirmed + return { + model: modifyFields(model, { + saving: Array.filter(saving => saving.seq !== seq), + confirmed: confirmed => (isNewest ? seq : confirmed), + load: load => + isNewest && load._tag === 'Loaded' ? LoadState.Loaded({ saved: settings, draft: load.draft }) : load, + }), + } + }, + + FailedSave: ({ seq, error }) => ({ + model: modifyFields(model, { + saving: Array.filter(saving => saving.seq !== seq), + maybeSaveError: () => Option.some(error), + }), + }), + }) + +// VIEW + +const testId = (h: HtmlBuilder, id: string) => h.DataAttribute('testid', id) + +const loadedView = (model: Model, saved: Settings, draft: Settings, h: HtmlBuilder): Html => { + const maybeGuess = pipe( + model.saving, + Array.filter(saving => saving.seq > model.confirmed), + Array.last, + ) + const shown = Option.match(maybeGuess, { onNone: () => saved, onSome: guess => guess.value }) + const error = nameError(draft.name) + return h.main([], [ + h.p( + [testId(h, 'saved')], + [`Saved: ${shown.name} · ${shown.theme} · ${shown.density}${Option.isSome(maybeGuess) ? ' (saving…)' : ''}`], + ), + Option.match(model.maybeSaveError, { + onNone: () => h.empty, + onSome: saveError => h.p([testId(h, 'save-error')], [`Save failed: ${saveError}`]), + }), + h.div([], [ + h.label([], [ + 'Display name ', + h.input([testId(h, 'name'), h.Value(draft.name), h.OnInput(value => Message.UpdatedName({ value }))]), + ]), + h.small([testId(h, 'name-count')], [`${draft.name.length}/20`]), + error === '' ? h.empty : h.p([testId(h, 'name-error')], [error]), + h.div([], [ + h.span([], [`Theme: ${draft.theme}`]), + h.button([testId(h, 'theme-light'), h.OnClick(Message.ClickedTheme({ theme: 'light' }))], ['light']), + h.button([testId(h, 'theme-dark'), h.OnClick(Message.ClickedTheme({ theme: 'dark' }))], ['dark']), + h.button([testId(h, 'density-toggle'), h.OnClick(Message.ClickedDensityToggle())], ['Toggle density']), + h.p([testId(h, 'preview')], [`Preview: ${draft.theme} / ${draft.density}`]), + ]), + h.button([testId(h, 'save'), h.OnClick(Message.ClickedSave())], ['Save']), + ]), + ]) +} + +export const view = Submodel.defineView((model, h) => + LoadState.match(model.load, { + Loading: () => h.p([testId(h, 'load')], ['Loading settings…']), + Failed: ({ error }) => + h.section([], [ + h.p([testId(h, 'load')], [`Could not load settings: ${error}`]), + h.button([testId(h, 'retry-load'), h.OnClick(Message.ClickedRetryLoad())], ['Retry']), + ]), + Loaded: ({ saved, draft }) => loadedView(model, saved, draft, h), + }), +) diff --git a/eval/reference/related/react/App.tsx b/eval/reference/related/react/App.tsx new file mode 100644 index 0000000..7f96584 --- /dev/null +++ b/eval/reference/related/react/App.tsx @@ -0,0 +1,54 @@ +import { useState, type ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import IssuesPage from "./IssuesPage"; +import HelpPage from "./HelpPage"; +import SettingsPage from "./SettingsPage"; + +type Page = "issues" | "help" | "settings"; + +// Each page gets its own query cache, made when the page opens, so leaving a page discards what it loaded. +function PageClient({ children }: { children: ReactNode }) { + const [client] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, refetchOnWindowFocus: false, refetchOnReconnect: false }, + }, + }), + ); + return {children}; +} + +export default function App() { + const [page, setPage] = useState("issues"); + // where the help page opens: empty from its tab, on an issue's title from a related hit + const [help, setHelp] = useState<{ query: string; selected: number | null; visit: number }>({ query: "", selected: null, visit: 0 }); + const openHelp = (query: string, selected: number | null) => { + setHelp(h => ({ query, selected, visit: h.visit + 1 })); + setPage("help"); + }; + return ( +
    + + + {page === "issues" ? ( + + ) : page === "help" ? ( + + ) : ( + + )} + +
    + ); +} diff --git a/eval/reference/related/react/CommentList.tsx b/eval/reference/related/react/CommentList.tsx new file mode 100644 index 0000000..3218198 --- /dev/null +++ b/eval/reference/related/react/CommentList.tsx @@ -0,0 +1,30 @@ +import type { Comment } from "./api"; +import { errorMessage } from "./errors"; + +export type Sending = { seq: number; issueId: number; author: string; text: string }; + +type Props = { + comments: Comment[] | undefined; + isPending: boolean; + error: unknown; + sending: Sending[]; +}; + +export default function CommentList({ comments, isPending, error, sending }: Props) { + if (isPending) return

    loading comments

    ; + if (error || !comments) return

    comments error: {errorMessage(error)}

    ; + return ( +
      + {comments.map((c, i) => ( +
    • + {c.author}: {c.text} +
    • + ))} + {sending.map(p => ( +
    • + {p.author}: {p.text} (sending…) +
    • + ))} +
    + ); +} diff --git a/eval/reference/related/react/HelpPage.tsx b/eval/reference/related/react/HelpPage.tsx new file mode 100644 index 0000000..beb7c91 --- /dev/null +++ b/eval/reference/related/react/HelpPage.tsx @@ -0,0 +1,86 @@ +import { useState } from "react"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { fetchItem, search, type Hit } from "./api"; + +// Opens on `initialQuery` with `initialSelected` chosen (both empty from the tab; a related hit opens it on its issue's +// title). +export default function HelpPage({ initialQuery = "", initialSelected = null }: { initialQuery?: string; initialSelected?: number | null }) { + const [query, setQuery] = useState(initialQuery); + const [selected, setSelected] = useState(initialSelected); + return ( +
    + setQuery(e.target.value)} />{" "} + {query !== "" && }{" "} + +
    + ); +} + +function Results({ query, selected, onSelect }: { query: string; selected: number | null; onSelect: (id: number) => void }) { + const tooShort = query.length < 2; + const { data, error, isFetching } = useQuery({ + queryKey: ["search", query], + queryFn: () => search(query), + enabled: !tooShort, + placeholderData: keepPreviousData, + }); + if (tooShort) + return ( +
    +

    too short: {query}

    +
    + ); + return ( +
    + {isFetching &&

    searching…

    } + {error && !isFetching ? ( +

    search failed: {error.message}

    + ) : ( + data && ( +
    +

    + {data.length} results for {query} +

    +
      + {data.map((hit: Hit) => ( +
    • onSelect(hit.id)}> + {hit.id === selected ? "> " : ""} + {hit.name} +
    • + ))} +
    +
    + ) + )} +
    + ); +} + +function HelpItem({ id }: { id: number | null }) { + const { data, error, isPending, refetch } = useQuery({ + queryKey: ["item", id], + queryFn: () => fetchItem(id!), + enabled: id !== null, + }); + if (id === null) return

    pick a result

    ; + return ( +
    + {isPending ? ( +

    loading…

    + ) : error ? ( + <> +

    failed: {error.message}

    {" "} + + + ) : data === null ? ( +

    item {id} not found

    + ) : ( +
    +

    {data.name}

    {data.blurb}

    +
    + )} +
    + ); +} diff --git a/eval/reference/related/react/IssueDetail.tsx b/eval/reference/related/react/IssueDetail.tsx new file mode 100644 index 0000000..26088bc --- /dev/null +++ b/eval/reference/related/react/IssueDetail.tsx @@ -0,0 +1,77 @@ +import type { Comment, IssueDetail as Detail } from "./api"; +import { errorMessage } from "./errors"; +import CommentList, { type Sending } from "./CommentList"; +import Related from "./Related"; + +type Props = { + id: number; + user: string; + detail: Detail | undefined; + loading: boolean; + error: unknown; + onRetry: () => void; + onAssign: () => void; + assignError: string | null; + titleInput: string; + onTitleInput: (title: string) => void; + onRename: () => void; + renameError: string | null; + onClose: () => void; + closeError: string | null; + comments: Comment[] | undefined; + commentsPending: boolean; + commentsError: unknown; + sending: Sending[]; + commentInput: string; + onCommentInput: (text: string) => void; + onPost: () => void; + postError: string | null; + onOpenHelp: (query: string, id: number) => void; +}; + +export default function IssueDetail(p: Props) { + let body; + if (p.loading) body =

    loading #{p.id}

    ; + else if (p.error || !p.detail) body =

    detail error: {errorMessage(p.error)}

    ; + else + body = ( + <> +

    + #{p.detail.id} {p.detail.title} +

    +

    {p.detail.body}

    + + + ); + return ( + <> + {body} + + + {p.assignError &&

    {p.assignError}

    } +
    + p.onTitleInput(e.target.value)} /> + + {p.renameError &&

    rename error: {p.renameError}

    } +
    + + {p.closeError &&

    close error: {p.closeError}

    } +
    + +
    + p.onCommentInput(e.target.value)} /> + + {p.postError &&

    post error: {p.postError}

    } + + ); +} diff --git a/eval/reference/related/react/IssueList.tsx b/eval/reference/related/react/IssueList.tsx new file mode 100644 index 0000000..576ad8b --- /dev/null +++ b/eval/reference/related/react/IssueList.tsx @@ -0,0 +1,27 @@ +import type { Issue } from "./api"; +import { errorMessage } from "./errors"; + +type Props = { + filter: "open" | "closed"; + issues: Issue[] | undefined; + isPending: boolean; + error: unknown; + selected: number | null; + onSelect: (id: number) => void; +}; + +export default function IssueList({ filter, issues, isPending, error, selected, onSelect }: Props) { + if (isPending) return

    loading {filter} issues

    ; + if (error || !issues) return

    list error: {errorMessage(error)}

    ; + return ( +
      + {issues.map(issue => ( +
    • + +
    • + ))} +
    + ); +} diff --git a/eval/reference/related/react/IssuesPage.tsx b/eval/reference/related/react/IssuesPage.tsx new file mode 100644 index 0000000..87924af --- /dev/null +++ b/eval/reference/related/react/IssuesPage.tsx @@ -0,0 +1,234 @@ +import { useRef, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { addComment, assign, closeIssue, fetchComments, fetchIssue, fetchIssues, renameIssue } from "./api"; +import type { Issue, IssueDetail as Detail } from "./api"; +import { errorMessage, type IssueError } from "./errors"; +import IssueList from "./IssueList"; +import IssueDetail from "./IssueDetail"; +import type { Sending } from "./CommentList"; + +type Filter = "open" | "closed"; +// a write still in the air: `seq` orders writes to the same issue, the latest one is what the issue shows +type Guess = { seq: number; value: T }; +type Guesses = Record[]>; + +const latest = (guesses: Guesses, id: number): T | undefined => guesses[id]?.at(-1)?.value; +const addGuess = (prev: Guesses, id: number, g: Guess): Guesses => ({ ...prev, [id]: [...(prev[id] ?? []), g] }); +const dropGuess = (prev: Guesses, id: number, seq: number): Guesses => { + const rest = (prev[id] ?? []).filter(g => g.seq !== seq); + const next = { ...prev }; + if (rest.length) next[id] = rest; + else delete next[id]; + return next; +}; + +export default function IssuesPage({ onOpenHelp }: { onOpenHelp: (query: string, id: number) => void }) { + const queryClient = useQueryClient(); + const [user, setUser] = useState("guest"); + const [filter, setFilter] = useState("open"); + const [selected, setSelected] = useState(null); + const seqRef = useRef(0); + const nextSeq = () => ++seqRef.current; + + // assignments in the air, and the newest one confirmed per issue (an older answer must not overwrite it) + const [assigning, setAssigning] = useState>({}); + const [assignError, setAssignError] = useState(null); + const lastAssignRef = useRef(0); + const confirmedAssignRef = useRef>({}); + + const [renaming, setRenaming] = useState>({}); + const [renameError, setRenameError] = useState(null); + const [titleInput, setTitleInput] = useState(""); + const lastRenameRef = useRef(0); + const confirmedRenameRef = useRef>({}); + + // closes in the air, by issue id, with how many are out + const [closing, setClosing] = useState>({}); + const [closeError, setCloseError] = useState(null); + const lastCloseRef = useRef(0); + + const [sending, setSending] = useState([]); + const [postError, setPostError] = useState(null); + const [commentInput, setCommentInput] = useState(""); + const lastPostRef = useRef(0); + + const list = useQuery({ queryKey: ["issues", filter], queryFn: () => fetchIssues(filter) }); + + const detail = useQuery({ + queryKey: ["issue", selected], + queryFn: async () => { + const id = selected as number; + const d = await fetchIssue(id); + if (d === null) throw new Error(`issue #${id} not found`); + return d; + }, + enabled: selected !== null, + }); + + const comments = useQuery({ + queryKey: ["comments", selected], + queryFn: () => fetchComments(selected as number), + enabled: selected !== null, + }); + + const shownIssues = list.data + ?.filter(issue => !(filter === "open" && closing[issue.id])) + .map(issue => ({ + ...issue, + title: latest(renaming, issue.id) ?? issue.title, + assignee: latest(assigning, issue.id) ?? issue.assignee, + })); + const shownDetail = detail.data && { ...detail.data, title: latest(renaming, detail.data.id) ?? detail.data.title }; + + const select = (id: number) => { + setSelected(id); + setTitleInput(""); + setAssignError(null); + setRenameError(null); + setCloseError(null); + setPostError(null); + }; + + const doAssign = async (id: number, who: string) => { + const seq = nextSeq(); + lastAssignRef.current = seq; + setAssignError(null); + setAssigning(prev => addGuess(prev, id, { seq, value: who })); + try { + const res = await assign(id, who); + if ((confirmedAssignRef.current[id] ?? 0) < seq) { + confirmedAssignRef.current[id] = seq; + queryClient.setQueriesData({ queryKey: ["issues"] }, old => + old?.map(i => (i.id === id ? { ...i, assignee: res.assignee } : i)), + ); + } + } catch (e) { + // only report the failure if no later assignment has started since + if (lastAssignRef.current === seq) setAssignError({ issueId: id, message: errorMessage(e) }); + } finally { + setAssigning(prev => dropGuess(prev, id, seq)); + } + }; + + const doRename = async (id: number, title: string) => { + const seq = nextSeq(); + lastRenameRef.current = seq; + setRenameError(null); + setRenaming(prev => addGuess(prev, id, { seq, value: title })); + try { + const res = await renameIssue(id, title); + if ((confirmedRenameRef.current[id] ?? 0) < seq) { + confirmedRenameRef.current[id] = seq; + queryClient.setQueriesData({ queryKey: ["issues"] }, old => + old?.map(i => (i.id === id ? { ...i, title: res.title } : i)), + ); + queryClient.setQueryData(["issue", id], old => (old ? { ...old, title: res.title } : old)); + } + } catch (e) { + if (lastRenameRef.current === seq) setRenameError({ issueId: id, message: errorMessage(e) }); + } finally { + setRenaming(prev => dropGuess(prev, id, seq)); + } + }; + + const doClose = async (id: number) => { + const seq = nextSeq(); + lastCloseRef.current = seq; + setCloseError(null); + setClosing(prev => ({ ...prev, [id]: (prev[id] ?? 0) + 1 })); + try { + await closeIssue(id); + queryClient.setQueryData(["issues", "open"], old => old?.filter(i => i.id !== id)); + } catch (e) { + if (lastCloseRef.current === seq) setCloseError({ issueId: id, message: errorMessage(e) }); + } finally { + setClosing(prev => { + const next = { ...prev }; + if ((next[id] ?? 0) > 1) next[id] -= 1; + else delete next[id]; + return next; + }); + } + }; + + const doPost = async (id: number, author: string, text: string) => { + const seq = nextSeq(); + lastPostRef.current = seq; + setPostError(null); + setCommentInput(""); + setSending(prev => [...prev, { seq, issueId: id, author, text }]); + try { + const res = await addComment(id, author, text); + queryClient.setQueryData<{ author: string; text: string }[]>(["comments", id], old => (old ? [...old, res] : old)); + } catch (e) { + if (lastPostRef.current === seq) setPostError({ issueId: id, message: errorMessage(e) }); + } finally { + setSending(prev => prev.filter(p => p.seq !== seq)); + } + }; + + const errorFor = (e: IssueError | null) => (e && e.issueId === selected ? e.message : null); + + return ( +
    +
    + +
    + + +
    +
    + +
    +
    +
    + {selected === null ? ( +

    select an issue

    + ) : ( + void detail.refetch()} + onAssign={() => void doAssign(selected, user)} + assignError={errorFor(assignError)} + titleInput={titleInput} + onTitleInput={setTitleInput} + onRename={() => { + if (titleInput !== "") void doRename(selected, titleInput); + }} + renameError={errorFor(renameError)} + onClose={() => void doClose(selected)} + closeError={errorFor(closeError)} + comments={comments.data} + commentsPending={comments.isPending} + commentsError={comments.error} + sending={sending.filter(p => p.issueId === selected)} + commentInput={commentInput} + onCommentInput={setCommentInput} + onPost={() => { + if (commentInput !== "") void doPost(selected, user, commentInput); + }} + postError={errorFor(postError)} + onOpenHelp={onOpenHelp} + /> + )} +
    +
    + ); +} diff --git a/eval/reference/related/react/Related.tsx b/eval/reference/related/react/Related.tsx new file mode 100644 index 0000000..497375d --- /dev/null +++ b/eval/reference/related/react/Related.tsx @@ -0,0 +1,29 @@ +import { useQuery } from "@tanstack/react-query"; +import { search } from "./api"; +import { errorMessage } from "./errors"; + +/** help related to an issue: the first hits of a search for its title */ +export default function Related({ title, onOpen }: { title: string; onOpen: (query: string, id: number) => void }) { + const hits = useQuery({ queryKey: ["related", title], queryFn: () => search(title) }); + return ( +
    + {hits.isPending ? ( +

    finding related help

    + ) : hits.isError ? ( +

    related help failed: {errorMessage(hits.error)}

    + ) : hits.data.length === 0 ? ( +

    no related help

    + ) : ( +
      + {hits.data.slice(0, 3).map(h => ( +
    • + +
    • + ))} +
    + )} +
    + ); +} diff --git a/eval/reference/related/react/SettingsPage.tsx b/eval/reference/related/react/SettingsPage.tsx new file mode 100644 index 0000000..d4ad74e --- /dev/null +++ b/eval/reference/related/react/SettingsPage.tsx @@ -0,0 +1,76 @@ +import { useRef, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { loadSettings, saveSettings, type Density, type Settings, type Theme } from "./api"; + +export default function SettingsPage() { + const queryClient = useQueryClient(); + const settings = useQuery({ queryKey: ["settings"], queryFn: loadSettings }); + const [saveError, setSaveError] = useState(""); + // saves still in the air, oldest first; the newest one shows until it answers or a newer one is confirmed + const [saving, setSaving] = useState<{ seq: number; value: Settings }[]>([]); + const seqRef = useRef(0); + const confirmedRef = useRef(0); + + const save = async (next: Settings) => { + const seq = ++seqRef.current; + setSaveError(""); + setSaving(prev => [...prev, { seq, value: next }]); + try { + const saved = await saveSettings(next); + if (seq > confirmedRef.current) { + confirmedRef.current = seq; + queryClient.setQueryData(["settings"], saved); + } + } catch (e) { + setSaveError(e instanceof Error ? e.message : String(e)); + } finally { + setSaving(prev => prev.filter(p => p.seq !== seq)); + } + }; + + if (settings.isPending) return

    Loading settings…

    ; + if (settings.error) return ( +
    +

    Could not load settings: {settings.error.message}

    {" "} + +
    + ); + const guess = saving.filter(p => p.seq > confirmedRef.current).at(-1); + const s = guess?.value ?? settings.data; + return ( +
    +

    Saved: {s.name} · {s.theme} · {s.density}{guess ? " (saving…)" : ""}

    {" "} + {saveError &&

    Save failed: {saveError}

    } + void save(draft)} /> +
    + ); +} + +function nameError(name: string) { + if (name.length === 0) return "Display name is required"; + if (name.length > 20) return `Display name must be at most 20 characters, not ${name.length}`; + return ""; +} + +// Seeded once from the loaded settings; after that the draft is the user's. +function SettingsForm({ initial, onSave }: { initial: Settings; onSave: (draft: Settings) => void }) { + const [name, setName] = useState(initial.name); + const [theme, setTheme] = useState(initial.theme); + const [density, setDensity] = useState(initial.density); + const error = nameError(name); + return ( +
    + {" "} + {name.length}/20{" "} + {error &&

    {error}

    } +
    + Theme: {theme}{" "} + {" "} + {" "} + {" "} +

    Preview: {theme} / {density}

    +
    + +
    + ); +} diff --git a/eval/reference/related/react/errors.ts b/eval/reference/related/react/errors.ts new file mode 100644 index 0000000..c380616 --- /dev/null +++ b/eval/reference/related/react/errors.ts @@ -0,0 +1,4 @@ +export const errorMessage = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +/** an error that belongs to one issue: shown only while that issue is selected */ +export type IssueError = { issueId: number; message: string }; diff --git a/eval/reference/related/rx/comments.rx b/eval/reference/related/rx/comments.rx new file mode 100644 index 0000000..3e9b7b2 --- /dev/null +++ b/eval/reference/related/rx/comments.rx @@ -0,0 +1,55 @@ +// The selected issue's comments, and posting one. Posts in flight live in their own source, so changing the +// selection never waits for them, and each shows only under its own issue. + +import pure { fetchComments } from "./api" +import { addComment } from "./api" + +export component Comments(props: { id: number; user: string }) { + source comments = await fetchComments(props.id) + source sending: { id: number; author: string; text: string }[] = [] + shownSending = sending.filter(s => s.id == props.id) + source postError: string | null = null + source commentInput: string := "" + + // a post error belongs to the issue it was made on: selecting another clears it + effect (props.id) { postError := null } + + action postComment(id: number, author: string, text: string) { + optimistic postError := null + try { + optimistic sending := [...sending, { id: id, author: author, text: text }] + await addComment(id, author, text) + refresh comments + } catch (e) { + postError := e.message + } + } + +
    +
    + {loading on(props.id) fallback {

    loading comments

    } { + {errored fallback(e) {

    comments error: {e.message}

    } { +
      + {for c in comments { +
    • {c.author}: {c.text}
    • + }} + {for s in shownSending { +
    • {s.author}: {s.text} (sending…)
    • + }} +
    + }} + }} +
    + { commentInput := e.target.value }} /> + + {if postError != null { +

    post error: {postError}

    + }} +
    +} diff --git a/eval/reference/related/rx/help.rx b/eval/reference/related/rx/help.rx new file mode 100644 index 0000000..267e779 --- /dev/null +++ b/eval/reference/related/rx/help.rx @@ -0,0 +1,53 @@ +// The help page: search the help catalogue and open one result. + +import pure { search, fetchItem } from "./api" + +export error TooShort(q: string) => `too short: ${q}` +export error ItemNotFound(id: number) => `item ${id} not found` + +fn foundItem(it, id: number) => it == null ? throw ItemNotFound(id) : it + +// Opens on `query` with `selected` chosen (both empty from the tab; a related hit opens it on its issue's title). +export component HelpPage(props: { query: string; selected: number | null }) { + source query: string := peek(props.query) + source selected: number | null := peek(props.selected) + results = query == "" ? [] : query.length < 2 ? throw TooShort(query) : await search(query) + item = selected == null ? null : foundItem(await fetchItem(selected), selected) + +
    + { query := e.target.value }} /> + {if query != "" { +
    + {errored fallback(e) {

    search failed: {e.message}

    } { + errored fallback(e: TooShort) {

    {e.message}

    } { + loading fallback {

    searching…

    } { +

    {pending? results ? "searching…" : ""}

    +

    {results.length} results for {latest query}

    +
      + {for r in results { +
    • { selected := r.id }}>{r.id == selected ? "> " : ""}{r.name}
    • + }} +
    + } + } + }} +
    + }} + {if selected == null { +

    pick a result

    + } else { +
    + {loading on(selected) fallback {

    loading…

    } { + {for id in [selected] { + {errored fallback(e) {

    failed: {e.message}

    } { + {errored fallback(e: ItemNotFound) {

    {e.message}

    } { +

    {item.name}

    +

    {item.blurb}

    + }} + }} + }} + }} +
    + }} +
    +} diff --git a/eval/reference/related/rx/issues.rx b/eval/reference/related/rx/issues.rx new file mode 100644 index 0000000..7cd65f6 --- /dev/null +++ b/eval/reference/related/rx/issues.rx @@ -0,0 +1,123 @@ +// The issues page: the filtered list, the selected issue's detail, and the writes on it (assign, rename, close). + +import pure { fetchIssues, fetchIssue } from "./api" +import { assign, renameIssue, closeIssue } from "./api" +import { Comments } from "./comments.rx" +import { Related } from "./related.rx" + +export error NotFound(id: number) => `issue #${id} not found` + +fn found(it, id: number) => it == null ? throw NotFound(id) : it + +export component IssuesPage(props: { onOpenHelp: (query: string, id: number) => void }) { + source user: string := "guest" + source filter: string := "open" + source selected: number | null := null + source issues key id = await fetchIssues(filter) + source detail = selected == null ? null : found(await fetchIssue(selected), selected) + source titleInput: string := "" + source assignError: string = "" + source renameError: string = "" + source closeError: string = "" + + action assignTo(id: number, who: string) { + optimistic assignError := "" + try { + let i = issues.findIndex(t => t.id == id) + optimistic issues[i].assignee := who + await assign(id, who) + refresh issues + } catch (e) { + assignError := e.message + } + } + + action renameTo(id: number, title: string) { + optimistic renameError := "" + try { + let i = issues.findIndex(t => t.id == id) + if (i >= 0) { + optimistic issues[i].title := title + } + if (detail != null && detail.id == id) { + optimistic detail.title := title + } + await renameIssue(id, title) + refresh issues + refresh detail + } catch (e) { + renameError := e.message + } + } + + action closeOne(id: number) { + optimistic closeError := "" + try { + optimistic issues := issues.filter(t => t.id != id) + await closeIssue(id) + refresh issues + } catch (e) { + closeError := e.message + } + } + +
    +
    + +
    +
    + + +
    +
    + {loading on(filter) fallback {

    loading {latest filter} issues

    } { + {errored fallback(e) {

    list error: {e.message}

    } { +
      + {for it in issues { +
    • { + selected := it.id + titleInput := "" + onFlush(() => { + assignError := "" + renameError := "" + closeError := "" + }) + }}>#{it.id} {it.title} ({it.assignee ?? "unassigned"})
    • + }} +
    + }} + }} +
    +
    + {if selected != null { + {loading on(selected) fallback {

    loading #{latest selected}

    } { + {errored fallback(e) {

    detail error: {e.message}

    } { +

    #{detail.id} {detail.title}

    +

    {detail.body}

    + { props.onOpenHelp(q, id) }} /> + }} + + }} + + {if assignError != "" { +

    {assignError}

    + }} + { titleInput := e.target.value }} /> + + {if renameError != "" { +

    rename error: {renameError}

    + }} + + {if closeError != "" { +

    close error: {closeError}

    + }} + + } else { +

    select an issue

    + }} +
    +
    +} diff --git a/eval/reference/related/rx/main.rx b/eval/reference/related/rx/main.rx new file mode 100644 index 0000000..7cf53c2 --- /dev/null +++ b/eval/reference/related/rx/main.rx @@ -0,0 +1,36 @@ +// The desk: three pages behind tabs. Only the open page is mounted, so leaving one discards its state. + +import { IssuesPage } from "./issues.rx" +import { HelpPage } from "./help.rx" +import { SettingsPage } from "./settings.rx" + +export default component App() { + source page: string := "issues" + source helpQuery: string := "" + source helpSelected: number | null := null + +
    + + {if page == "issues" { + { + helpQuery := q + helpSelected := id + page := "help" + }} /> + } else { + {if page == "help" { + + } else { + + }} + }} +
    +} diff --git a/eval/reference/related/rx/related.rx b/eval/reference/related/rx/related.rx new file mode 100644 index 0000000..aea6011 --- /dev/null +++ b/eval/reference/related/rx/related.rx @@ -0,0 +1,23 @@ +// Help related to an issue: the first hits of a search for its title. + +import pure { search } from "./api" + +export component Related(props: { title: string; onOpen: (query: string, id: number) => void }) { + hits = await search(props.title) + +
    + {loading on(props.title) fallback {

    finding related help

    } { + {errored fallback(e) {

    related help failed: {e.message}

    } { + {if hits.length == 0 { +

    no related help

    + } else { +
      + {for h in hits.slice(0, 3) { +
    • + }} +
    + }} + }} + }} +
    +} diff --git a/eval/reference/related/rx/settings.rx b/eval/reference/related/rx/settings.rx new file mode 100644 index 0000000..0df8231 --- /dev/null +++ b/eval/reference/related/rx/settings.rx @@ -0,0 +1,61 @@ +// The settings page: the saved settings, a draft form seeded from them, and saving. + +import pure { loadSettings } from "./api" +import { saveSettings, Settings } from "./api" + +export error NameRequired() => "Display name is required" +export error NameTooLong(count: number) => `Display name must be at most 20 characters, not ${count}` + +fn validName(name: string): string => + name.length == 0 ? throw NameRequired() + : name.length > 20 ? throw NameTooLong(name.length) + : name + +export component SettingsPage() { + source settings: Settings = await loadSettings() + source saveError: string = "" + + action save(next: Settings) { + optimistic settings := next + optimistic saveError := "" + affects settings + try { + let saved = await saveSettings(next) + settings := saved + } catch (e) { + saveError := e.message + } + } + + errored fallback(e) { +

    Could not load settings: {e.message}

    + + } { + loading fallback {

    Loading settings…

    } { + source draftName: string := peek(settings.name) + source draftTheme: string := peek(settings.theme) + source draftDensity: string := peek(settings.density) + nameError: string = (validName(draftName) && "") catch (e: NameRequired | NameTooLong) => e.message +

    Saved: {settings.name} · {settings.theme} · {settings.density}{pending? settings ? " (saving…)" : ""}

    + {if saveError != "" { +

    Save failed: {saveError}

    + }} + {" "} + {draftName.length}/20 + {if nameError != "" { +

    {nameError}

    + }} +
    + {" "} + {" "} + +
    +

    Preview: {draftTheme} / {draftDensity}

    + + } + } +} diff --git a/eval/reference/related/solid/App.tsx b/eval/reference/related/solid/App.tsx new file mode 100644 index 0000000..c4b5c1c --- /dev/null +++ b/eval/reference/related/solid/App.tsx @@ -0,0 +1,31 @@ +import { createSignal, Match, Switch } from "solid-js"; +import IssuesPage from "./IssuesPage"; +import HelpPage from "./HelpPage"; +import SettingsPage from "./SettingsPage"; + +type Page = "issues" | "help" | "settings"; + +// Only the open page is mounted, so leaving one discards its state. +export default function App() { + const [page, setPage] = createSignal("issues"); + // where the help page opens: empty from its tab, on an issue's title from a related hit + const [help, setHelp] = createSignal<{ query: string; selected: number | null }>({ query: "", selected: null }); + const openHelp = (query: string, selected: number | null) => { + setHelp({ query, selected }); + setPage("help"); + }; + return ( +
    + + + + + + +
    + ); +} diff --git a/eval/reference/related/solid/Comments.tsx b/eval/reference/related/solid/Comments.tsx new file mode 100644 index 0000000..1518ac3 --- /dev/null +++ b/eval/reference/related/solid/Comments.tsx @@ -0,0 +1,65 @@ +import { action, createEffect, createMemo, createOptimisticStore, createSignal, Errored, For, Loading, onFlush, refresh, Show } from "solid-js"; +import { addComment, fetchComments } from "./api"; + +const message = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +type Sending = { id: number; author: string; text: string }; + +// The selected issue's comments, and posting one. Posts in flight live in their own store, so changing the selection +// never waits for them, and each shows only under its own issue. +export default function Comments(props: { id: number; user: string }) { + const comments = createMemo(() => fetchComments(props.id)); + const [sending, setSending] = createOptimisticStore([]); + const [postError, setPostError] = createSignal(""); + const [commentInput, setCommentInput] = createSignal(""); + + // a post error belongs to the issue it was made on: selecting another clears it + createEffect(() => props.id, () => { + setPostError(""); + }); + + const post = action(function* (id: number, author: string, text: string) { + try { + setSending(s => { s.push({ id, author, text }); }); + yield addComment(id, author, text); + refresh(comments); + } catch (e) { + setPostError(message(e)); + } + }); + + return ( +
    +
    + loading comments

    }> +

    comments error: {message(e())}

    }> +
      + {c =>
    • {c.author}: {c.text}
    • }
      + s.id === props.id)}> + {s =>
    • {s.author}: {s.text} (sending…)
    • } +
      +
    +
    +
    +
    + setCommentInput(e.currentTarget.value)} /> + + +

    post error: {postError()}

    +
    +
    + ); +} diff --git a/eval/reference/related/solid/HelpPage.tsx b/eval/reference/related/solid/HelpPage.tsx new file mode 100644 index 0000000..8be8472 --- /dev/null +++ b/eval/reference/related/solid/HelpPage.tsx @@ -0,0 +1,83 @@ +import { createMemo, createSignal, Errored, For, isPending, latest, Loading, refresh, Show, untrack } from "solid-js"; +import { fetchItem, search } from "./api"; + +const message = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +class TooShort extends Error {} +class ItemNotFound extends Error {} + +// Opens on `query` with `selected` chosen (both empty from the tab; a related hit opens it on its issue's title). +export default function HelpPage(props: { query: string; selected: number | null }) { + const [query, setQuery] = createSignal(untrack(() => props.query)); + const [selected, setSelected] = createSignal(untrack(() => props.selected)); + + // empty: nothing; one character: too short, nothing asked; otherwise the search + const results = createMemo(() => { + const q = query(); + if (q === "") return []; + if (q.length < 2) throw new TooShort(`too short: ${q}`); + return search(q); + }); + const item = createMemo(async () => { + const id = selected(); + if (id === null) return null; + const it = await fetchItem(id); + if (it === null) throw new ItemNotFound(`item ${id} not found`); + return it; + }); + + return ( +
    + {/* the input shows what was typed even while the search it started is in flight */} + setQuery(e.currentTarget.value)} /> + +
    + (e() instanceof TooShort ?

    {message(e())}

    :

    search failed: {message(e())}

    )}> + searching…

    }> +

    {isPending(() => results()) ? "searching…" : ""}

    +

    + {results().length} results for {latest(query)} +

    +
      + + {r => ( +
    • setSelected(r.id)}> + {r.id === selected() ? "> " : ""} + {r.name} +
    • + )} +
      +
    +
    +
    +
    +
    + pick a result

    }> +
    + loading…

    }> + {/* a fresh region per selection, so a failure shown for one item doesn't stay up while the next loads */} + + {_id => ( + + e() instanceof ItemNotFound ? ( +

    {message(e())}

    + ) : ( + <> +

    failed: {message(e())}

    + + + ) + } + > +

    {item()?.name}

    +

    {item()?.blurb}

    +
    + )} +
    +
    +
    +
    +
    + ); +} diff --git a/eval/reference/related/solid/IssuesPage.tsx b/eval/reference/related/solid/IssuesPage.tsx new file mode 100644 index 0000000..e832433 --- /dev/null +++ b/eval/reference/related/solid/IssuesPage.tsx @@ -0,0 +1,157 @@ +import { action, createOptimistic, createOptimisticStore, createSignal, Errored, For, latest, Loading, onFlush, refresh, Show } from "solid-js"; +import { assign, closeIssue, fetchIssue, fetchIssues, renameIssue, type Issue, type IssueDetail } from "./api"; +import Comments from "./Comments"; +import Related from "./Related"; + +const message = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +export default function IssuesPage(props: { onOpenHelp: (query: string, id: number) => void }) { + const [user, setUser] = createSignal("guest"); + const [filter, setFilter] = createSignal<"open" | "closed">("open"); + const [selected, setSelected] = createSignal(null); + const [titleInput, setTitleInput] = createSignal(""); + const [assignError, setAssignError] = createSignal(""); + const [renameError, setRenameError] = createSignal(""); + const [closeError, setCloseError] = createSignal(""); + + // the list for the filter; writes in an action are guesses until it settles + const [issues, setIssues] = createOptimisticStore(() => fetchIssues(filter()), [], { key: "id" }); + const [detail, setDetail] = createOptimistic(async () => { + const id = selected(); + if (id === null) return null; + const d = await fetchIssue(id); + if (d === null) throw new Error(`issue #${id} not found`); + return d; + }); + + const assignTo = action(function* (id: number, who: string) { + try { + setIssues(list => { + const row = list.find(t => t.id === id); + if (row) row.assignee = who; + }); + yield assign(id, who); + refresh(issues); + } catch (e) { + setAssignError(message(e)); + } + }); + + const renameTo = action(function* (id: number, title: string) { + try { + setIssues(list => { + const row = list.find(t => t.id === id); + if (row) row.title = title; + }); + setDetail(d => (d && d.id === id ? { ...d, title } : d)); + yield renameIssue(id, title); + refresh(issues); + refresh(detail); + } catch (e) { + setRenameError(message(e)); + } + }); + + const closeOne = action(function* (id: number) { + try { + setIssues(list => { + const i = list.findIndex(t => t.id === id); + if (i >= 0) list.splice(i, 1); + }); + yield closeIssue(id); + refresh(issues); + } catch (e) { + setCloseError(message(e)); + } + }); + + const select = (id: number) => { + setSelected(id); + setTitleInput(""); + onFlush(() => { + setAssignError(""); + setRenameError(""); + setCloseError(""); + }); + }; + + return ( +
    +
    + +
    +
    + + +
    +
    + loading {latest(filter)} issues

    }> +

    list error: {message(e())}

    }> +
      + + {it => ( +
    • select(it.id)}> + #{it.id} {it.title} ({it.assignee ?? "unassigned"}) +
    • + )} +
      +
    +
    +
    +
    +
    + select an issue

    }> + loading #{latest(selected)}

    }> +

    detail error: {message(e())}

    }> +

    #{detail()?.id} {detail()?.title}

    +

    {detail()?.body}

    + {d => } +
    + +
    + + +

    {assignError()}

    +
    + setTitleInput(e.currentTarget.value)} /> + + +

    rename error: {renameError()}

    +
    + + +

    close error: {closeError()}

    +
    + +
    +
    +
    + ); +} diff --git a/eval/reference/related/solid/Related.tsx b/eval/reference/related/solid/Related.tsx new file mode 100644 index 0000000..db4a529 --- /dev/null +++ b/eval/reference/related/solid/Related.tsx @@ -0,0 +1,28 @@ +import { createMemo, Errored, For, Loading, Show } from "solid-js"; +import { search } from "./api"; + +const message = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +/** help related to an issue: the first hits of a search for its title */ +export default function Related(props: { title: string; onOpen: (query: string, id: number) => void }) { + const hits = createMemo(() => search(props.title)); + return ( +
    + finding related help

    }> +

    related help failed: {message(e())}

    }> + 0} fallback={

    no related help

    }> +
      + + {h => ( +
    • + +
    • + )} +
      +
    +
    +
    +
    +
    + ); +} diff --git a/eval/reference/related/solid/SettingsPage.tsx b/eval/reference/related/solid/SettingsPage.tsx new file mode 100644 index 0000000..5e949d3 --- /dev/null +++ b/eval/reference/related/solid/SettingsPage.tsx @@ -0,0 +1,92 @@ +import { action, affects, createOptimistic, createSignal, Errored, isPending, Loading, onFlush, refresh, Show, untrack } from "solid-js"; +import { loadSettings, saveSettings, type Density, type Settings, type Theme } from "./api"; + +const message = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +function nameError(name: string) { + if (name.length === 0) return "Display name is required"; + if (name.length > 20) return `Display name must be at most 20 characters, not ${name.length}`; + return ""; +} + +export default function SettingsPage() { + // what the server has; a save shows its settings over it as a guess until it settles + const [saved, setSaved] = createSignal(() => loadSettings()); + const [settings, setSettings] = createOptimistic(() => saved()); + const [saveError, setSaveError] = createSignal(""); + + const save = action(function* (next: Settings) { + setSettings(next); + affects(settings); + try { + setSaved(yield saveSettings(next)); + } catch (e) { + setSaveError(message(e)); + } + }); + + return ( + ( + <> +

    Could not load settings: {message(e())}

    + + + )} + > + Loading settings…

    }> +

    + Saved: {settings().name} · {settings().theme} · {settings().density} + {isPending(() => settings()) ? " (saving…)" : ""} +

    + +

    Save failed: {saveError()}

    +
    + { + onFlush(() => setSaveError("")); + save(s); + }} + /> +
    +
    + ); +} + +// Seeded once from the loaded settings; after that the draft is the user's. +function SettingsForm(props: { initial: Settings; onSave: (s: Settings) => void }) { + const [name, setName] = createSignal(untrack(() => props.initial.name)); + const [theme, setTheme] = createSignal(untrack(() => props.initial.theme)); + const [density, setDensity] = createSignal(untrack(() => props.initial.density)); + const error = () => nameError(name()); + return ( +
    + {" "} + {name().length}/20 + +

    {error()}

    +
    +
    + {" "} + {" "} + +
    +

    + Preview: {theme()} / {density()} +

    + +
    + ); +} diff --git a/eval/results/chain-2026-10-30/README.md b/eval/results/chain-2026-10-30/README.md new file mode 100644 index 0000000..acc7233 --- /dev/null +++ b/eval/results/chain-2026-10-30/README.md @@ -0,0 +1,84 @@ +# Round 7 results: maintenance chains + +Pre-registration: `eval/PREREG-chain-7.md` (4d6670f). 6 chains per arm, 4 steps each (labels, open-issue count, +related help, row actions), each step a fresh agent changing the app the previous agent left. 48 runs on 2026-10-30; +each step's 12 runs launched together. Scored with `harness/score.mjs --overlap`, chained with `harness/chain.mjs` +(`chain.txt`, `chain.json`). + +## Primary and secondary hypotheses: not supported, reversed + +| | React | .rx | +| --- | --- | --- | +| Chains ending with a defect (H7a) | 0 / 6 | 4 / 6 | +| Runs with a new defect (H7b) | 0 / 24 | 4 / 24 | +| Runs with a regression (H7c) | 0 / 24 | 4 / 24 | +| Mean cost per chain (billed, without each run's first call) | 305,627 | 503,888 | + +One-sided tests in the pre-registered direction (React worse): p = 1.0 for H7a, H7b and H7c. Cost, React − .rx: p = 1.0; +the other way (.rx costs more): p = 0.0006. + +Per step: + +| Step | React defects | .rx defects | React mean cost | .rx mean cost | +| --- | --- | --- | --- | --- | +| 1 labels | 0 | 0 | 76,281 | 98,802 | +| 2 count | 0 | 0 | 60,440 | 71,399 | +| 3 related help | 0 | 0 | 86,469 | 78,497 | +| 4 row actions | 0 | 4 | 82,437 | 255,190 | + +All four .rx defects are at step 4 and all are regressions of the desk's own tests. The #4425 test (alongside, as in +round 5) failed in every .rx run from step 3 on, as in the references. + +## What happened + +**React guards did not decay.** Every React run left every earlier test passing. Step-4 React agents reused the +detail's `doAssign` / `doClose` with a `fromRow` flag; five of six read the existing guard code closely enough to find +and fix a latent flaw in it (an older assignment still out could show over a newer accepted one). The hand-written +guards were read and extended, not broken. + +**.rx step 4 hit H-11.** Row actions put several guesses on `issues` at once (a row close next to a detail close, two +assignments of one issue). In .rx, guesses on one source revert together and an action's error write is held with +its change, so a declined close stays on screen and its error stays hidden until every other change on the list +settles. Every .rx agent found this with its own extra tests (5 to 10 `./check`/`./test` runs, against React's 2-3) and +worked around it: + +- 5 of 6 moved closing from a whole-list guess to a per-row `closing` flag, adding fields to the list's rows; +- all 6 replaced `optimistic err := ""` and held error writes with `onFlush` writes, so errors show at once (one agent + noted this lets a late assign error show under another issue). + +That is the 3x cost of step 4 (255k against 82k). + +**Three of the four defects are a Solid bug the workaround reaches.** Runs rx-2, rx-4 and rx-6 wrapped the loader to +add the flag: `source issues key id = asRows(await fetchIssues(filter), filter)`. The compiler hoists the `await` into a +memo that the optimistic store's derive reads. When that load fails, the error never reaches the list's `errored` +boundary: the list keeps showing `loading closed issues` (test "a failing list leaves the detail alone"). Plain +`source issues key id = await fetchIssues(filter)` passes the same test (checked by editing one step-3 app both ways). +In Solid at the pinned next (5e68925c), `createOptimisticStore(fn)` whose `fn` reads a failed `createMemo` swallows +the error, where `createStore(fn)` delivers it: repro `solid-optimistic-store-error.test.ts` here (runs in solid `packages/signals/tests`) +(filed as solidjs/solid#4437). rx-3, which kept the flag on the plain source, passed. + +**The fourth (rx-5)** kept a separate `closing` source derived from `issues` and guessed on it; after a filter switch +and reload the list shows empty rows (`# ()`) or none. Not yet reduced; it may be the agent's design or another runtime +bug. + +## Reading + +The claim tested was that hand-written guards decay as fresh agents change an app again and again. In four steps they +did not: frontier agents read the guards they inherited and extended them correctly, even improved them. .rx did not +fail on guards; it failed where the language could not express what the change needed (independent guesses on one +list, an error shown while another change on the same source is out), and the agents' workarounds then met a runtime +bug. The two .rx-specific causes are a language limit (H-11, already an open decision) and a Solid bug in +`createOptimisticStore`. + +## Limits + +- Six chains per arm, four steps; a longer chain may still show decay. +- Step 4 was written by the same author as the references, who knew the .rx reference passes it only under the H-11 + allowance the round 5 tests already make (a declined guess may linger until the other settles). The agents were not + told of that allowance and built for the stricter reading of the brief. +- Not run: Solid, Foldkit. + +## Files + +`score.json` (per run), `chain.txt`, `chain.json` (per run new defects, regressions, cost; per arm totals and tests), +`agents.txt`. Runs in `/home/claude/runs/r7` (not committed). diff --git a/eval/results/chain-2026-10-30/agents.txt b/eval/results/chain-2026-10-30/agents.txt new file mode 100644 index 0000000..24c7d32 --- /dev/null +++ b/eval/results/chain-2026-10-30/agents.txt @@ -0,0 +1,48 @@ +chain1-react-1 a472882d98d50631f +chain1-react-2 a94c8c1b016adc93f +chain1-react-3 acb238423fec47d12 +chain1-react-4 ad41745d7214716f3 +chain1-react-5 aa41cd928ba8b8a03 +chain1-react-6 a736cebf162e9b790 +chain1-rx-1 a1859235f12a541df +chain1-rx-2 a305a123d074e2bb3 +chain1-rx-3 a5c4b6757a070c760 +chain1-rx-4 a864bcc55988cbae7 +chain1-rx-5 a037fef5c3327e077 +chain1-rx-6 a57a056daa31a7094 +chain2-react-1 ad3c1e9b8cfae3927 +chain2-react-2 a591099bfbcae3db9 +chain2-react-3 a29c7163d02b842bc +chain2-react-4 a14e900d22a4f19f1 +chain2-react-5 a40961d8a709ecd53 +chain2-react-6 aebff665a6debd74c +chain2-rx-1 a0a59ba3011c531b5 +chain2-rx-2 ab40dd678f16a59e3 +chain2-rx-3 a3682e5624bccf9b4 +chain2-rx-4 a805ff864a03feeb2 +chain2-rx-5 a1e3d00f53f518c19 +chain2-rx-6 ac9e2113b9dd5fa2d +chain3-react-1 a1431b81e05b89bef +chain3-react-2 a773e7e0252e6fead +chain3-react-3 aced0299766576495 +chain3-react-4 a520df4bcaeeff000 +chain3-react-5 a1fcee1b7b03bc0f0 +chain3-react-6 ae44e46635e4a5515 +chain3-rx-1 a23a331c11d7fb804 +chain3-rx-2 a57aeaf609aef3e59 +chain3-rx-3 a66078c6e6b53861c +chain3-rx-4 a9da82f25c502b3f9 +chain3-rx-5 aeca58313f308300d +chain3-rx-6 a851916f567b33dc4 +chain4-react-1 a14034c2404dfe3d7 +chain4-react-2 a0a4cad0c1c4223b4 +chain4-react-3 a39ba2e82b6ddaa49 +chain4-react-4 a6775d9e7b3f75401 +chain4-react-5 abd9e54c2d970ffa6 +chain4-react-6 aa4eff492c36d06dc +chain4-rx-1 ad8484854fe0066fe +chain4-rx-2 abcc14030fa2712c6 +chain4-rx-3 a2e14c9d1b64468fc +chain4-rx-4 ab954730d1181b4db +chain4-rx-5 a1946d29da6967e30 +chain4-rx-6 ae125672d5c4b5454 diff --git a/eval/results/chain-2026-10-30/chain.json b/eval/results/chain-2026-10-30/chain.json new file mode 100644 index 0000000..7b3dce7 --- /dev/null +++ b/eval/results/chain-2026-10-30/chain.json @@ -0,0 +1,654 @@ +{ + "last": 4, + "arms": { + "react": { + "chains": 6, + "endDefect": 0, + "runs": 24, + "runsWithNew": 0, + "runsWithRegression": 0, + "perStep": { + "1": { + "defect": 0, + "new": 0, + "meanCost": 76281 + }, + "2": { + "defect": 0, + "new": 0, + "meanCost": 60440 + }, + "3": { + "defect": 0, + "new": 0, + "meanCost": 86469 + }, + "4": { + "defect": 0, + "new": 0, + "meanCost": 82437 + } + }, + "chainCost": [ + 290380, + 290644, + 319790, + 320138, + 312382, + 300426 + ] + }, + "rx": { + "chains": 6, + "endDefect": 4, + "runs": 24, + "runsWithNew": 4, + "runsWithRegression": 4, + "perStep": { + "1": { + "defect": 0, + "new": 0, + "meanCost": 98802 + }, + "2": { + "defect": 0, + "new": 0, + "meanCost": 71399 + }, + "3": { + "defect": 0, + "new": 0, + "meanCost": 78497 + }, + "4": { + "defect": 4, + "new": 4, + "meanCost": 255190 + } + }, + "chainCost": [ + 466455, + 474531, + 529070, + 519128, + 531448, + 502695 + ] + } + }, + "H7a": { + "react": "0/6", + "rx": "4/6", + "p": 0.9999999999999991 + }, + "H7b": { + "react": "0/24", + "rx": "4/24", + "p": 0.9999999999999943 + }, + "H7c": { + "react": "0/24", + "rx": "4/24", + "p": 0.9999999999999943 + }, + "cost": { + "reactMinusRx": 1, + "rxMinusReact": 0.0005899941000589994 + }, + "runs": [ + { + "run": "chain1-react-1", + "step": 1, + "arm": "react", + "chain": 1, + "defect": false, + "failing": [], + "new": [], + "regressions": [], + "cost": 86467.65 + }, + { + "run": "chain2-react-1", + "step": 2, + "arm": "react", + "chain": 1, + "defect": false, + "failing": [], + "new": [], + "regressions": [], + "cost": 56876.649999999994 + }, + { + "run": "chain3-react-1", + "step": 3, + "arm": "react", + "chain": 1, + "defect": false, + "failing": [], + "new": [], + "regressions": [], + "cost": 83842.15000000002 + }, + { + "run": "chain4-react-1", + "step": 4, + "arm": "react", + "chain": 1, + "defect": false, + "failing": [], + "new": [], + "regressions": [], + "cost": 63193.6 + }, + { + "run": "chain1-react-2", + "step": 1, + "arm": "react", + "chain": 2, + "defect": false, + "failing": [], + "new": [], + "regressions": [], + "cost": 71761.05 + }, + { + "run": "chain2-react-2", + "step": 2, + "arm": "react", + "chain": 2, + "defect": false, + "failing": [], + "new": [], + "regressions": [], + "cost": 55516.2 + }, + { + "run": "chain3-react-2", + "step": 3, + "arm": "react", + "chain": 2, + "defect": false, + "failing": [], + "new": [], + "regressions": [], + "cost": 75503.05 + }, + { + "run": "chain4-react-2", + "step": 4, + "arm": "react", + "chain": 2, + "defect": false, + "failing": [], + "new": [], + "regressions": [], + "cost": 87864.05 + }, + { + "run": "chain1-react-3", + "step": 1, + "arm": "react", + "chain": 3, + "defect": false, + "failing": [], + "new": [], + "regressions": [], + "cost": 75822.95 + }, + { + "run": "chain2-react-3", + "step": 2, + "arm": "react", + "chain": 3, + "defect": false, + "failing": [], + "new": [], + "regressions": [], + "cost": 53321.950000000004 + }, + { + "run": "chain3-react-3", + "step": 3, + "arm": "react", + "chain": 3, + "defect": false, + "failing": [], + "new": [], + "regressions": [], + "cost": 106048.5 + }, + { + "run": "chain4-react-3", + "step": 4, + "arm": "react", + "chain": 3, + "defect": false, + "failing": [], + "new": [], + "regressions": [], + "cost": 84596.90000000001 + }, + { + "run": "chain1-react-4", + "step": 1, + "arm": "react", + "chain": 4, + "defect": false, + "failing": [], + "new": [], + "regressions": [], + "cost": 74378.95000000001 + }, + { + "run": "chain2-react-4", + "step": 2, + "arm": "react", + "chain": 4, + "defect": false, + "failing": [], + "new": [], + "regressions": [], + "cost": 77609.79999999999 + }, + { + "run": "chain3-react-4", + "step": 3, + "arm": "react", + "chain": 4, + "defect": false, + "failing": [], + "new": [], + "regressions": [], + "cost": 82390.6 + }, + { + "run": "chain4-react-4", + "step": 4, + "arm": "react", + "chain": 4, + "defect": false, + "failing": [], + "new": [], + "regressions": [], + "cost": 85758.5 + }, + { + "run": "chain1-react-5", + "step": 1, + "arm": "react", + "chain": 5, + "defect": false, + "failing": [], + "new": [], + "regressions": [], + "cost": 73997.45 + }, + { + "run": "chain2-react-5", + "step": 2, + "arm": "react", + "chain": 5, + "defect": false, + "failing": [], + "new": [], + "regressions": [], + "cost": 62244.74999999999 + }, + { + "run": "chain3-react-5", + "step": 3, + "arm": "react", + "chain": 5, + "defect": false, + "failing": [], + "new": [], + "regressions": [], + "cost": 84657.95000000001 + }, + { + "run": "chain4-react-5", + "step": 4, + "arm": "react", + "chain": 5, + "defect": false, + "failing": [], + "new": [], + "regressions": [], + "cost": 91481.7 + }, + { + "run": "chain1-react-6", + "step": 1, + "arm": "react", + "chain": 6, + "defect": false, + "failing": [], + "new": [], + "regressions": [], + "cost": 75256.3 + }, + { + "run": "chain2-react-6", + "step": 2, + "arm": "react", + "chain": 6, + "defect": false, + "failing": [], + "new": [], + "regressions": [], + "cost": 57070.1 + }, + { + "run": "chain3-react-6", + "step": 3, + "arm": "react", + "chain": 6, + "defect": false, + "failing": [], + "new": [], + "regressions": [], + "cost": 86372.54999999999 + }, + { + "run": "chain4-react-6", + "step": 4, + "arm": "react", + "chain": 6, + "defect": false, + "failing": [], + "new": [], + "regressions": [], + "cost": 81727.25 + }, + { + "run": "chain1-rx-1", + "step": 1, + "arm": "rx", + "chain": 1, + "defect": false, + "failing": [], + "new": [], + "regressions": [], + "cost": 83305.30000000002 + }, + { + "run": "chain2-rx-1", + "step": 2, + "arm": "rx", + "chain": 1, + "defect": false, + "failing": [], + "new": [], + "regressions": [], + "cost": 63771.40000000001 + }, + { + "run": "chain3-rx-1", + "step": 3, + "arm": "rx", + "chain": 1, + "defect": false, + "failing": [], + "new": [], + "regressions": [], + "cost": 84735.84999999999 + }, + { + "run": "chain4-rx-1", + "step": 4, + "arm": "rx", + "chain": 1, + "defect": false, + "failing": [], + "new": [], + "regressions": [], + "cost": 234642.25000000003 + }, + { + "run": "chain1-rx-2", + "step": 1, + "arm": "rx", + "chain": 2, + "defect": false, + "failing": [], + "new": [], + "regressions": [], + "cost": 78992.99999999999 + }, + { + "run": "chain2-rx-2", + "step": 2, + "arm": "rx", + "chain": 2, + "defect": false, + "failing": [], + "new": [], + "regressions": [], + "cost": 84954.9 + }, + { + "run": "chain3-rx-2", + "step": 3, + "arm": "rx", + "chain": 2, + "defect": false, + "failing": [], + "new": [], + "regressions": [], + "cost": 78729.8 + }, + { + "run": "chain4-rx-2", + "step": 4, + "arm": "rx", + "chain": 2, + "defect": true, + "failing": [ + "hidden: base: a failing list leaves the detail alone" + ], + "new": [ + "hidden: base: a failing list leaves the detail alone" + ], + "regressions": [ + "hidden: base: a failing list leaves the detail alone" + ], + "cost": 231853.40000000002 + }, + { + "run": "chain1-rx-3", + "step": 1, + "arm": "rx", + "chain": 3, + "defect": false, + "failing": [], + "new": [], + "regressions": [], + "cost": 114184.44999999998 + }, + { + "run": "chain2-rx-3", + "step": 2, + "arm": "rx", + "chain": 3, + "defect": false, + "failing": [], + "new": [], + "regressions": [], + "cost": 72047.90000000001 + }, + { + "run": "chain3-rx-3", + "step": 3, + "arm": "rx", + "chain": 3, + "defect": false, + "failing": [], + "new": [], + "regressions": [], + "cost": 77088.55000000002 + }, + { + "run": "chain4-rx-3", + "step": 4, + "arm": "rx", + "chain": 3, + "defect": false, + "failing": [], + "new": [], + "regressions": [], + "cost": 265749.45 + }, + { + "run": "chain1-rx-4", + "step": 1, + "arm": "rx", + "chain": 4, + "defect": false, + "failing": [], + "new": [], + "regressions": [], + "cost": 99611.75 + }, + { + "run": "chain2-rx-4", + "step": 2, + "arm": "rx", + "chain": 4, + "defect": false, + "failing": [], + "new": [], + "regressions": [], + "cost": 57092.45 + }, + { + "run": "chain3-rx-4", + "step": 3, + "arm": "rx", + "chain": 4, + "defect": false, + "failing": [], + "new": [], + "regressions": [], + "cost": 64070.95 + }, + { + "run": "chain4-rx-4", + "step": 4, + "arm": "rx", + "chain": 4, + "defect": true, + "failing": [ + "hidden: base: a failing list leaves the detail alone" + ], + "new": [ + "hidden: base: a failing list leaves the detail alone" + ], + "regressions": [ + "hidden: base: a failing list leaves the detail alone" + ], + "cost": 298352.35 + }, + { + "run": "chain1-rx-5", + "step": 1, + "arm": "rx", + "chain": 5, + "defect": false, + "failing": [], + "new": [], + "regressions": [], + "cost": 114088.4 + }, + { + "run": "chain2-rx-5", + "step": 2, + "arm": "rx", + "chain": 5, + "defect": false, + "failing": [], + "new": [], + "regressions": [], + "cost": 69701.3 + }, + { + "run": "chain3-rx-5", + "step": 3, + "arm": "rx", + "chain": 5, + "defect": false, + "failing": [], + "new": [], + "regressions": [], + "cost": 85121.7 + }, + { + "run": "chain4-rx-5", + "step": 4, + "arm": "rx", + "chain": 5, + "defect": true, + "failing": [ + "hidden: base: a list that loads while a close is out keeps the row out", + "hidden: rows: open issues that load while a row close is out keep the row out" + ], + "new": [ + "hidden: base: a list that loads while a close is out keeps the row out", + "hidden: rows: open issues that load while a row close is out keep the row out" + ], + "regressions": [ + "hidden: base: a list that loads while a close is out keeps the row out" + ], + "cost": 262537.05 + }, + { + "run": "chain1-rx-6", + "step": 1, + "arm": "rx", + "chain": 6, + "defect": false, + "failing": [], + "new": [], + "regressions": [], + "cost": 102626.40000000002 + }, + { + "run": "chain2-rx-6", + "step": 2, + "arm": "rx", + "chain": 6, + "defect": false, + "failing": [], + "new": [], + "regressions": [], + "cost": 80826.15 + }, + { + "run": "chain3-rx-6", + "step": 3, + "arm": "rx", + "chain": 6, + "defect": false, + "failing": [], + "new": [], + "regressions": [], + "cost": 81237.75000000001 + }, + { + "run": "chain4-rx-6", + "step": 4, + "arm": "rx", + "chain": 6, + "defect": true, + "failing": [ + "hidden: base: a failing list leaves the detail alone" + ], + "new": [ + "hidden: base: a failing list leaves the detail alone" + ], + "regressions": [ + "hidden: base: a failing list leaves the detail alone" + ], + "cost": 238005.15000000002 + } + ] +} diff --git a/eval/results/chain-2026-10-30/chain.txt b/eval/results/chain-2026-10-30/chain.txt new file mode 100644 index 0000000..16d3242 --- /dev/null +++ b/eval/results/chain-2026-10-30/chain.txt @@ -0,0 +1,151 @@ +chain1-react-1 ok new 0 regressions 0 cost 86468 +chain2-react-1 ok new 0 regressions 0 cost 56877 +chain3-react-1 ok new 0 regressions 0 cost 83842 +chain4-react-1 ok new 0 regressions 0 cost 63194 +chain1-react-2 ok new 0 regressions 0 cost 71761 +chain2-react-2 ok new 0 regressions 0 cost 55516 +chain3-react-2 ok new 0 regressions 0 cost 75503 +chain4-react-2 ok new 0 regressions 0 cost 87864 +chain1-react-3 ok new 0 regressions 0 cost 75823 +chain2-react-3 ok new 0 regressions 0 cost 53322 +chain3-react-3 ok new 0 regressions 0 cost 106049 +chain4-react-3 ok new 0 regressions 0 cost 84597 +chain1-react-4 ok new 0 regressions 0 cost 74379 +chain2-react-4 ok new 0 regressions 0 cost 77610 +chain3-react-4 ok new 0 regressions 0 cost 82391 +chain4-react-4 ok new 0 regressions 0 cost 85759 +chain1-react-5 ok new 0 regressions 0 cost 73997 +chain2-react-5 ok new 0 regressions 0 cost 62245 +chain3-react-5 ok new 0 regressions 0 cost 84658 +chain4-react-5 ok new 0 regressions 0 cost 91482 +chain1-react-6 ok new 0 regressions 0 cost 75256 +chain2-react-6 ok new 0 regressions 0 cost 57070 +chain3-react-6 ok new 0 regressions 0 cost 86373 +chain4-react-6 ok new 0 regressions 0 cost 81727 +chain1-rx-1 ok new 0 regressions 0 cost 83305 +chain2-rx-1 ok new 0 regressions 0 cost 63771 +chain3-rx-1 ok new 0 regressions 0 cost 84736 +chain4-rx-1 ok new 0 regressions 0 cost 234642 +chain1-rx-2 ok new 0 regressions 0 cost 78993 +chain2-rx-2 ok new 0 regressions 0 cost 84955 +chain3-rx-2 ok new 0 regressions 0 cost 78730 +chain4-rx-2 DEFECT new 1 regressions 1 cost 231853 + hidden: base: a failing list leaves the detail alone +chain1-rx-3 ok new 0 regressions 0 cost 114184 +chain2-rx-3 ok new 0 regressions 0 cost 72048 +chain3-rx-3 ok new 0 regressions 0 cost 77089 +chain4-rx-3 ok new 0 regressions 0 cost 265749 +chain1-rx-4 ok new 0 regressions 0 cost 99612 +chain2-rx-4 ok new 0 regressions 0 cost 57092 +chain3-rx-4 ok new 0 regressions 0 cost 64071 +chain4-rx-4 DEFECT new 1 regressions 1 cost 298352 + hidden: base: a failing list leaves the detail alone +chain1-rx-5 ok new 0 regressions 0 cost 114088 +chain2-rx-5 ok new 0 regressions 0 cost 69701 +chain3-rx-5 ok new 0 regressions 0 cost 85122 +chain4-rx-5 DEFECT new 2 regressions 1 cost 262537 + hidden: base: a list that loads while a close is out keeps the row out + hidden: rows: open issues that load while a row close is out keep the row out +chain1-rx-6 ok new 0 regressions 0 cost 102626 +chain2-rx-6 ok new 0 regressions 0 cost 80826 +chain3-rx-6 ok new 0 regressions 0 cost 81238 +chain4-rx-6 DEFECT new 1 regressions 1 cost 238005 + hidden: base: a failing list leaves the detail alone +{ + "last": 4, + "arms": { + "react": { + "chains": 6, + "endDefect": 0, + "runs": 24, + "runsWithNew": 0, + "runsWithRegression": 0, + "perStep": { + "1": { + "defect": 0, + "new": 0, + "meanCost": 76281 + }, + "2": { + "defect": 0, + "new": 0, + "meanCost": 60440 + }, + "3": { + "defect": 0, + "new": 0, + "meanCost": 86469 + }, + "4": { + "defect": 0, + "new": 0, + "meanCost": 82437 + } + }, + "chainCost": [ + 290380, + 290644, + 319790, + 320138, + 312382, + 300426 + ] + }, + "rx": { + "chains": 6, + "endDefect": 4, + "runs": 24, + "runsWithNew": 4, + "runsWithRegression": 4, + "perStep": { + "1": { + "defect": 0, + "new": 0, + "meanCost": 98802 + }, + "2": { + "defect": 0, + "new": 0, + "meanCost": 71399 + }, + "3": { + "defect": 0, + "new": 0, + "meanCost": 78497 + }, + "4": { + "defect": 4, + "new": 4, + "meanCost": 255190 + } + }, + "chainCost": [ + 466455, + 474531, + 529070, + 519128, + 531448, + 502695 + ] + } + }, + "H7a": { + "react": "0/6", + "rx": "4/6", + "p": 0.9999999999999991 + }, + "H7b": { + "react": "0/24", + "rx": "4/24", + "p": 0.9999999999999943 + }, + "H7c": { + "react": "0/24", + "rx": "4/24", + "p": 0.9999999999999943 + }, + "cost": { + "reactMinusRx": 1, + "rxMinusReact": 0.0005899941000589994 + } +} diff --git a/eval/results/chain-2026-10-30/score.json b/eval/results/chain-2026-10-30/score.json new file mode 100644 index 0000000..22d15e2 --- /dev/null +++ b/eval/results/chain-2026-10-30/score.json @@ -0,0 +1,998 @@ +[ + { + "run": "chain1-react-1", + "task": "chain1", + "arm": "react", + "n": 1, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "22/22", + "hidden": "58/58", + "overlap": "26/26", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "chain1-react-2", + "task": "chain1", + "arm": "react", + "n": 2, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "22/22", + "hidden": "58/58", + "overlap": "26/26", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "chain1-react-3", + "task": "chain1", + "arm": "react", + "n": 3, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "22/22", + "hidden": "58/58", + "overlap": "26/26", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "chain1-react-4", + "task": "chain1", + "arm": "react", + "n": 4, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "22/22", + "hidden": "58/58", + "overlap": "26/26", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "chain1-react-5", + "task": "chain1", + "arm": "react", + "n": 5, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "22/22", + "hidden": "58/58", + "overlap": "26/26", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "chain1-react-6", + "task": "chain1", + "arm": "react", + "n": 6, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "22/22", + "hidden": "58/58", + "overlap": "26/26", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "chain1-rx-1", + "task": "chain1", + "arm": "rx", + "n": 1, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "22/22", + "hidden": "58/58", + "overlap": "26/26", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "chain1-rx-2", + "task": "chain1", + "arm": "rx", + "n": 2, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "22/22", + "hidden": "58/58", + "overlap": "26/26", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "chain1-rx-3", + "task": "chain1", + "arm": "rx", + "n": 3, + "checks": 1, + "tests": 2, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "22/22", + "hidden": "58/58", + "overlap": "26/26", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "chain1-rx-4", + "task": "chain1", + "arm": "rx", + "n": 4, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "22/22", + "hidden": "58/58", + "overlap": "26/26", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "chain1-rx-5", + "task": "chain1", + "arm": "rx", + "n": 5, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "22/22", + "hidden": "58/58", + "overlap": "26/26", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "chain1-rx-6", + "task": "chain1", + "arm": "rx", + "n": 6, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "22/22", + "hidden": "58/58", + "overlap": "26/26", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "chain2-react-1", + "task": "chain2", + "arm": "react", + "n": 1, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "24/24", + "hidden": "64/64", + "overlap": "29/29", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "chain2-react-2", + "task": "chain2", + "arm": "react", + "n": 2, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "24/24", + "hidden": "64/64", + "overlap": "29/29", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "chain2-react-3", + "task": "chain2", + "arm": "react", + "n": 3, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "24/24", + "hidden": "64/64", + "overlap": "29/29", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "chain2-react-4", + "task": "chain2", + "arm": "react", + "n": 4, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "24/24", + "hidden": "64/64", + "overlap": "29/29", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "chain2-react-5", + "task": "chain2", + "arm": "react", + "n": 5, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "24/24", + "hidden": "64/64", + "overlap": "29/29", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "chain2-react-6", + "task": "chain2", + "arm": "react", + "n": 6, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "24/24", + "hidden": "64/64", + "overlap": "29/29", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "chain2-rx-1", + "task": "chain2", + "arm": "rx", + "n": 1, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "24/24", + "hidden": "64/64", + "overlap": "29/29", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "chain2-rx-2", + "task": "chain2", + "arm": "rx", + "n": 2, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "24/24", + "hidden": "64/64", + "overlap": "29/29", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "chain2-rx-3", + "task": "chain2", + "arm": "rx", + "n": 3, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "24/24", + "hidden": "64/64", + "overlap": "29/29", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "chain2-rx-4", + "task": "chain2", + "arm": "rx", + "n": 4, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "24/24", + "hidden": "64/64", + "overlap": "29/29", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "chain2-rx-5", + "task": "chain2", + "arm": "rx", + "n": 5, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "24/24", + "hidden": "64/64", + "overlap": "29/29", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "chain2-rx-6", + "task": "chain2", + "arm": "rx", + "n": 6, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "24/24", + "hidden": "64/64", + "overlap": "29/29", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "chain3-react-1", + "task": "chain3", + "arm": "react", + "n": 1, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "26/26", + "hidden": "70/70", + "overlap": "32/32", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "chain3-react-2", + "task": "chain3", + "arm": "react", + "n": 2, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "26/26", + "hidden": "70/70", + "overlap": "32/32", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "chain3-react-3", + "task": "chain3", + "arm": "react", + "n": 3, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "26/26", + "hidden": "70/70", + "overlap": "32/32", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "chain3-react-4", + "task": "chain3", + "arm": "react", + "n": 4, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "26/26", + "hidden": "70/70", + "overlap": "32/32", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "chain3-react-5", + "task": "chain3", + "arm": "react", + "n": 5, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "26/26", + "hidden": "70/70", + "overlap": "32/32", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "chain3-react-6", + "task": "chain3", + "arm": "react", + "n": 6, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "26/26", + "hidden": "70/70", + "overlap": "32/32", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "chain3-rx-1", + "task": "chain3", + "arm": "rx", + "n": 1, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "26/26", + "hidden": "70/70", + "overlap": "32/32", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [ + { + "name": "base: retry after not found shows the issue", + "error": "expected [detail] to show \"stack trace attached\", it shows \"detail error: issue #3 not foundretryassign to guestrenamecloseloading labelsadd labelloading commentspost\"" + } + ], + "failedVisible": [] + }, + { + "run": "chain3-rx-2", + "task": "chain3", + "arm": "rx", + "n": 2, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "26/26", + "hidden": "70/70", + "overlap": "32/32", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [ + { + "name": "base: retry after not found shows the issue", + "error": "expected [detail] to show \"stack trace attached\", it shows \"detail error: issue #3 not foundretryassign to guestrenamecloseloading labelsadd labelloading commentspost\"" + } + ], + "failedVisible": [] + }, + { + "run": "chain3-rx-3", + "task": "chain3", + "arm": "rx", + "n": 3, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "26/26", + "hidden": "70/70", + "overlap": "32/32", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [ + { + "name": "base: retry after not found shows the issue", + "error": "expected [detail] to show \"stack trace attached\", it shows \"detail error: issue #3 not foundretryassign to guestrenamecloseloading labelsadd labelloading commentspost\"" + } + ], + "failedVisible": [] + }, + { + "run": "chain3-rx-4", + "task": "chain3", + "arm": "rx", + "n": 4, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "26/26", + "hidden": "70/70", + "overlap": "32/32", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [ + { + "name": "base: retry after not found shows the issue", + "error": "expected [detail] to show \"stack trace attached\", it shows \"detail error: issue #3 not foundretryassign to guestrenamecloseloading labelsadd labelloading commentspost\"" + } + ], + "failedVisible": [] + }, + { + "run": "chain3-rx-5", + "task": "chain3", + "arm": "rx", + "n": 5, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "26/26", + "hidden": "70/70", + "overlap": "32/32", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [ + { + "name": "base: retry after not found shows the issue", + "error": "expected [detail] to show \"stack trace attached\", it shows \"detail error: issue #3 not foundretryassign to guestrenamecloseloading commentspostloading labelsadd label\"" + } + ], + "failedVisible": [] + }, + { + "run": "chain3-rx-6", + "task": "chain3", + "arm": "rx", + "n": 6, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "26/26", + "hidden": "70/70", + "overlap": "32/32", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [ + { + "name": "base: retry after not found shows the issue", + "error": "expected [detail] to show \"stack trace attached\", it shows \"detail error: issue #3 not foundretryassign to guestrenamecloseloading labelsadd labelloading commentspost\"" + } + ], + "failedVisible": [] + }, + { + "run": "chain4-react-1", + "task": "chain4", + "arm": "react", + "n": 1, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "30/30", + "hidden": "77/77", + "overlap": "38/38", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "chain4-react-2", + "task": "chain4", + "arm": "react", + "n": 2, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "30/30", + "hidden": "77/77", + "overlap": "38/38", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "chain4-react-3", + "task": "chain4", + "arm": "react", + "n": 3, + "checks": 2, + "tests": 1, + "cyclesToGreen": 3, + "checkOk": true, + "built": true, + "visible": "30/30", + "hidden": "77/77", + "overlap": "38/38", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "chain4-react-4", + "task": "chain4", + "arm": "react", + "n": 4, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "30/30", + "hidden": "77/77", + "overlap": "38/38", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "chain4-react-5", + "task": "chain4", + "arm": "react", + "n": 5, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "30/30", + "hidden": "77/77", + "overlap": "38/38", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "chain4-react-6", + "task": "chain4", + "arm": "react", + "n": 6, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "30/30", + "hidden": "77/77", + "overlap": "38/38", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "chain4-rx-1", + "task": "chain4", + "arm": "rx", + "n": 1, + "checks": 2, + "tests": 4, + "cyclesToGreen": 3, + "checkOk": true, + "built": true, + "visible": "30/30", + "hidden": "77/77", + "overlap": "38/38", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [ + { + "name": "base: retry after not found shows the issue", + "error": "expected [detail] to show \"stack trace attached\", it shows \"detail error: issue #3 not foundretryassign to guestrenamecloseloading labelsadd labelloading commentspost\"" + } + ], + "failedVisible": [] + }, + { + "run": "chain4-rx-2", + "task": "chain4", + "arm": "rx", + "n": 2, + "checks": 2, + "tests": 3, + "cyclesToGreen": 3, + "checkOk": true, + "built": true, + "visible": "30/30", + "hidden": "76/77", + "overlap": "38/38", + "failedOverlap": [], + "shippedDefect": true, + "failedHidden": [ + { + "name": "base: a failing list leaves the detail alone", + "error": "expected [list] to show \"list error: timeout\", it shows \"loading closed issues\"" + } + ], + "failedAlongside": [ + { + "name": "base: retry after not found shows the issue", + "error": "expected [detail] to show \"stack trace attached\", it shows \"detail error: issue #3 not foundretryassign to guestrenamecloseloading labelsadd labelloading commentspost\"" + } + ], + "failedVisible": [] + }, + { + "run": "chain4-rx-3", + "task": "chain4", + "arm": "rx", + "n": 3, + "checks": 3, + "tests": 6, + "cyclesToGreen": 4, + "checkOk": true, + "built": true, + "visible": "30/30", + "hidden": "77/77", + "overlap": "38/38", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [ + { + "name": "base: retry after not found shows the issue", + "error": "expected [detail] to show \"stack trace attached\", it shows \"detail error: issue #3 not foundretryassign to guestrenamecloseloading labelsadd labelloading commentspost\"" + } + ], + "failedVisible": [] + }, + { + "run": "chain4-rx-4", + "task": "chain4", + "arm": "rx", + "n": 4, + "checks": 4, + "tests": 5, + "cyclesToGreen": 5, + "checkOk": true, + "built": true, + "visible": "30/30", + "hidden": "76/77", + "overlap": "38/38", + "failedOverlap": [], + "shippedDefect": true, + "failedHidden": [ + { + "name": "base: a failing list leaves the detail alone", + "error": "expected [list] to show \"list error: timeout\", it shows \"loading closed issues\"" + } + ], + "failedAlongside": [ + { + "name": "base: retry after not found shows the issue", + "error": "expected [detail] to show \"stack trace attached\", it shows \"detail error: issue #3 not foundretryassign to guestrenamecloseloading labelsadd labelloading commentspost\"" + } + ], + "failedVisible": [] + }, + { + "run": "chain4-rx-5", + "task": "chain4", + "arm": "rx", + "n": 5, + "checks": 2, + "tests": 4, + "cyclesToGreen": 5, + "checkOk": true, + "built": true, + "visible": "30/30", + "hidden": "77/77", + "overlap": "36/38", + "failedOverlap": [ + { + "name": "base: a list that loads while a close is out keeps the row out", + "error": "expected [issue] rows [\"#1 Login fails (unassigned)\",\"#4 Typo in footer (dana)\"], got [\"# ()\",\"# ()\"]" + }, + { + "name": "rows: open issues that load while a row close is out keep the row out", + "error": "expected [issue] rows [\"#1 Login fails (unassigned)\",\"#4 Typo in footer (dana)\"], got []" + } + ], + "shippedDefect": true, + "failedHidden": [], + "failedAlongside": [ + { + "name": "base: retry after not found shows the issue", + "error": "expected [detail] to show \"stack trace attached\", it shows \"detail error: issue #3 not foundretryassign to guestrenamecloseloading commentspostloading labelsadd label\"" + } + ], + "failedVisible": [] + }, + { + "run": "chain4-rx-6", + "task": "chain4", + "arm": "rx", + "n": 6, + "checks": 2, + "tests": 3, + "cyclesToGreen": 3, + "checkOk": true, + "built": true, + "visible": "30/30", + "hidden": "76/77", + "overlap": "38/38", + "failedOverlap": [], + "shippedDefect": true, + "failedHidden": [ + { + "name": "base: a failing list leaves the detail alone", + "error": "expected [list] to show \"list error: timeout\", it shows \"loading closed issues\"" + } + ], + "failedAlongside": [ + { + "name": "base: retry after not found shows the issue", + "error": "expected [detail] to show \"stack trace attached\", it shows \"detail error: issue #3 not foundretryassign to guestrenamecloseloading labelsadd labelloading commentspost\"" + } + ], + "failedVisible": [] + } +] diff --git a/eval/results/chain-2026-10-30/solid-optimistic-store-error.test.ts b/eval/results/chain-2026-10-30/solid-optimistic-store-error.test.ts new file mode 100644 index 0000000..d12db52 --- /dev/null +++ b/eval/results/chain-2026-10-30/solid-optimistic-store-error.test.ts @@ -0,0 +1,23 @@ +import { it } from "vitest"; +import { createEffect, createMemo, createOptimisticStore, createStore, createRoot, createSignal, flush } from "../src/index.js"; +const tick = async () => { for (let i = 0; i < 4; i++) { await new Promise(r => setTimeout(r, 0)); flush(); } }; +for (const kind of ["optimistic", "store"]) for (const hoisted of ["no", "async", "sync"]) +it(`${kind} over a failing loader, hoisted=${hoisted}`, async () => { + const asks: { key: string; res: (v: any) => void; rej: (e: any) => void }[] = []; + const ask = (key: string) => new Promise((res, rej) => asks.push({ key, res, rej })); + const settle = async (key: string, f: (a: any) => void) => { frames.push(`asks[${asks.map(a => a.key)}]`); for (const a of asks.filter(a => a.key === key)) { asks.splice(asks.indexOf(a), 1); f(a); } await tick(); }; + const frames: string[] = []; let setFilter!: (f: string) => void; + createRoot(() => { + const [filter, sf] = createSignal("open"); setFilter = sf; + const m = createMemo(() => ask(`f:${filter()}`)); + const make: any = kind === "store" ? createStore : createOptimisticStore; + const write = (d: any[], r: any) => { d.length = 0; d.push(...r.map((x: any) => ({ ...x }))); }; + const [issues] = make(hoisted === "sync" ? (d: any[]) => { write(d, m()); } : async (d: any[]) => { write(d, hoisted === "async" ? m() : await ask(`f:${filter()}`)); }, [], { key: "id" }); + createEffect(() => (issues as any[]).map(i => `#${i.id}`).join(" "), { effect: (v: string) => { frames.push(v); }, error: (e: any) => { frames.push(`ERR ${e?.message ?? e}`); } } as any); + }); + await tick(); + await settle("f:open", a => a.res([{ id: 1 }, { id: 3 }])); + setFilter("closed"); flush(); await tick(); + await settle("f:closed", a => a.rej(new Error("timeout"))); + console.log(`${kind} hoisted=${hoisted}: ` + frames.join(" | ")); +}); diff --git a/eval/results/change-2026-10-28/README.md b/eval/results/change-2026-10-28/README.md new file mode 100644 index 0000000..0462488 --- /dev/null +++ b/eval/results/change-2026-10-28/README.md @@ -0,0 +1,114 @@ +# Round 4 result: changing an existing app + +Pre-registration: `../../PREREG-change.md` (commit 4ba4c71, before any run). 36 fresh runs: 3 tasks (rename, close, +comments) × 2 arms × 2 models (the frontier model, and Haiku as the small one) × 3 runs, each starting from round 3's +frontier-model tracker app of its arm. No run had to be repeated. Six small-model runs ended without sending their +report and were asked for it once; none changed its app after being asked. + +## Primary result: H1 not supported + +| Defects shipped | React | .rx | Fisher, two-sided | +| --- | --- | --- | --- | +| All (pre-registered test) | 0 / 18 | 3 / 18 | p = 0.23 | +| Frontier model | 0 / 9 | 0 / 9 | p = 1.0 | +| Small model | 0 / 9 | 3 / 9 | p = 0.21 | + +A defect is a failed build or any failed visible, hidden or hidden-overlap test (`score.mjs --overlap`), except the +one test reported alongside (below). Per task: rename 0/6 vs 1/6, close 0/6 vs 1/6, comments 0/6 vs 1/6. No run in +either arm broke a base-app test. + +Neither direction is significant. The frontier model shipped no defect in either arm. All three defects are .rx +apps written by the small model. + +## Every failure, classified + +| Run | Test failed | Class | Cause | +| --- | --- | --- | --- | +| .rx small close-1 | two closes at once: a declined one does not undo the other | lost update | `let before = closedIds` before the await, then `closedIds := [...before, id]` after it: an accepted close writes a list from before the other close, so the declined row stays hidden | +| .rx small comments-2 | two posts at once: a declined one does not take the other with it | lost update | the same shape: `let before = comments`, then `comments := [...before, accepted]` after the await, which keeps the other post's `(sending…)` row | +| .rx small comments-2 | comments that load while a post is out keep the post | lost update | that write overwrites the comments source with the list from before the post, dropping the reloaded answer | +| .rx small rename-1 | an earlier rename declined after a later one was accepted keeps the later title | missing refresh | `optimistic detail := …` with `refresh issues` but no `refresh detail`: when the guess ends, the heading shows the title it fetched before the rename | + +Two of the three defects are one pattern: a value read before an `await` and written back after it. It compiles +and passes the checker. In React the same shape is the classic stale-closure bug; no React run wrote it. + +**Not counted: rename-1's missing refresh also shows on a single accepted rename.** An exploratory probe (not +pre-registered) renames once, lets the server accept, and checks the heading: small rename-rx-1 shows the old title, +the other 17 rename runs pass. No pre-registered test covered that case, so it does not change any count. + +## Reported alongside, outside the count + +"A post on one issue does not show on another" (solidjs/solid#4336): .rx comments runs failed it in 4 of 6 (frontier +1 of 3, small 3 of 3); React 0 of 6. The four apps make the whole-list guess the guide describes; the guess stays on +screen after the selected issue changes, until the post settles. The two frontier .rx runs that pass keep pending +posts in a list of their own (`sending`, `outbox`) beside the fetched comments. + +| Mean per run | React, frontier | .rx, frontier | React, small | .rx, small | +| --- | --- | --- | --- | --- | +| Agent tokens | 47.8k | 56.0k | 56.7k | 68.8k | +| `./check` + `./test` cycles | 2.2 | 3.6 | 2.2 | 2.7 | +| Context above the shared start (agent tokens minus ~37k of system prompt and tools) | 10.8k | 18.7k | 19.7k | 31.6k | +| ... of which reading `docs/` | 0 | 7.6k | 0 | 8.6k | +| ... the rest | 10.8k | 11.1k | 19.7k | 23.0k | + +Agent tokens are the agent's context at its end, so every run carries the same ~37k start. Reading the docs is +measured from the transcripts as the size of tool results whose call names `docs/`, `guide` or `spec/` (3.5 +characters per token), an estimate. + +- **PR #17 (inferred actions, AC-1b/1c/1d)** was merged after the runs' toolchain was frozen. Rechecked on main + (e25b81b + #14): all 18 .rx apps pass the new checker, and rescoring them with main's compiler and runtime gives the + same 3 defects. Every .rx run used an explicit `action`; the lost-update writes come after the await from a read + made before it, which neither HELD_WRITE nor STALE_OWN_READ covers. +- How the .rx close and comments runs held a pending write: the frontier model guessed per key (`hidden[id]`, + `rowStates[i].closed`) or kept pending posts in a list of their own (`sending`, `outbox`) in 4 of 6, and guessed the + whole list in 2. The small model guessed the whole list or id set in 5 of 6; small close-rx-3 hid the row with a + plain write in the click handler and undid it in the action on a decline. Most React runs extended the base app's + own pending entries and sequence numbers to the new write. + +## Known limits, as found + +- **Run agents could reach the project thread's reply tools.** Two small-model runs (rename-rx-1, comments-react-3) + posted a short version of their final report into the thread with them. Their tool calls were checked: + no run read the thread, the project chat or any message, so nothing reached the runs. Several runs also called + `no_reply_needed`, and one small run read a documents guide; neither carries anything about the tasks. +- **Ryan's one-word "Merge" (about PR #14, sent while the runs were going) was delivered into every run agent's + context** as a note from the project owner. It says nothing about the tasks, and no run acted on it (no merge or GitHub call; + one run ran `git status` in its own folder after restoring a test file). +- Six small-model runs needed a reminder to send their report (above). +- The bases are frontier-model apps; the small model changes code it would not have written. +- The new tests were written after round 3's results were known, and tuned on the references. +- 18 runs per arm detects a large effect only. + +## Found after scoring: a check for the lost update + +`STALE_WRITE_BACK` (06 AC-1e, taken by default 2026-10-28, added after these runs) rejects a plain write after an +`await` computed from the same source as read before it. Run over every .rx app the eval's agents have written (76, +pilot to round 4) and the repository's examples, it fires on three: small close-rx-1 and small comments-rx-2 (the two +lost-update defects above), and frontier rename-rx-3, which passed every test. That one writes +`detail := { id: d.id, title: answer.title, body: d.body }` from a `d` read before the rename's `await`; an +exploratory probe confirms it: switch to #1 while a rename of #3 is out, and when the rename lands #1's detail shows +#3. The 6 React rename apps and the other 5 .rx ones pass that probe. None of this changes the pre-registered count. + +A rule for the third defect (a guess with no `refresh` of its source) was drafted and dropped: a guess can be answered +by a write to anything its question reads, and a guess meant to revert is legitimate, so it would reject correct +programs. + +## Changed after the round + +Ryan ruled on 2026-10-28, with solidjs/solid#4337, that a selection change made while a whole-list guess is out +holds with the guess's action. The alongside test ("a post on one issue does not show on another") now accepts the +screen staying on #3 until the post settles. It still fails if the post shows under #1. The round above used the +frozen file (SHA in the pre-registration), and its count does not change. `guide/async.md` now teaches keeping the +pending items in a source of their own when the data depends on a selection. + +## What this means + +Changing an app did not separate the arms either. The frontier model made all three changes correctly in both +languages. The small model did so in React, and in .rx shipped one pattern twice that the checker does not see: a +read before an await written back after it. The static check for it (above) is now in the compiler. + +## Files + +- `results-big.json`, `results-small.json`: `score.mjs --overlap` output (`failedAlongside` holds the #4336 test). +- `runs///`: each agent's own source files and its `./check`/`./test` log. +- `usage.txt`: agent tokens per run. diff --git a/eval/results/change-2026-10-28/results-big.json b/eval/results/change-2026-10-28/results-big.json new file mode 100644 index 0000000..23d762e --- /dev/null +++ b/eval/results/change-2026-10-28/results-big.json @@ -0,0 +1,349 @@ +[ + { + "run": "close-react-1", + "task": "close", + "arm": "react", + "n": 1, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "6/6", + "hidden": "15/15", + "overlap": "6/6", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "close-react-2", + "task": "close", + "arm": "react", + "n": 2, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "6/6", + "hidden": "15/15", + "overlap": "6/6", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "close-react-3", + "task": "close", + "arm": "react", + "n": 3, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "6/6", + "hidden": "15/15", + "overlap": "6/6", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "close-rx-1", + "task": "close", + "arm": "rx", + "n": 1, + "checks": 2, + "tests": 2, + "cyclesToGreen": 3, + "checkOk": true, + "built": true, + "visible": "6/6", + "hidden": "15/15", + "overlap": "6/6", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "close-rx-2", + "task": "close", + "arm": "rx", + "n": 2, + "checks": 3, + "tests": 2, + "cyclesToGreen": 3, + "checkOk": true, + "built": true, + "visible": "6/6", + "hidden": "15/15", + "overlap": "6/6", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "close-rx-3", + "task": "close", + "arm": "rx", + "n": 3, + "checks": 2, + "tests": 1, + "cyclesToGreen": 3, + "checkOk": true, + "built": true, + "visible": "6/6", + "hidden": "15/15", + "overlap": "6/6", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "comments-react-1", + "task": "comments", + "arm": "react", + "n": 1, + "checks": 2, + "tests": 2, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "6/6", + "hidden": "17/17", + "overlap": "6/6", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "comments-react-2", + "task": "comments", + "arm": "react", + "n": 2, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "6/6", + "hidden": "17/17", + "overlap": "6/6", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "comments-react-3", + "task": "comments", + "arm": "react", + "n": 3, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "6/6", + "hidden": "17/17", + "overlap": "6/6", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "comments-rx-1", + "task": "comments", + "arm": "rx", + "n": 1, + "checks": 2, + "tests": 2, + "cyclesToGreen": 3, + "checkOk": true, + "built": true, + "visible": "6/6", + "hidden": "17/17", + "overlap": "6/6", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "comments-rx-2", + "task": "comments", + "arm": "rx", + "n": 2, + "checks": 2, + "tests": 1, + "cyclesToGreen": 3, + "checkOk": true, + "built": true, + "visible": "6/6", + "hidden": "17/17", + "overlap": "6/6", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [ + { + "name": "a post on one issue does not show on another", + "error": "expected [comments] not to show \"on it\", it shows \"dana: seen it tooann: on it (sending…)\"" + } + ], + "failedVisible": [] + }, + { + "run": "comments-rx-3", + "task": "comments", + "arm": "rx", + "n": 3, + "checks": 2, + "tests": 1, + "cyclesToGreen": 3, + "checkOk": true, + "built": true, + "visible": "6/6", + "hidden": "17/17", + "overlap": "6/6", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "rename-react-1", + "task": "rename", + "arm": "react", + "n": 1, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "6/6", + "hidden": "16/16", + "overlap": "7/7", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "rename-react-2", + "task": "rename", + "arm": "react", + "n": 2, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "6/6", + "hidden": "16/16", + "overlap": "7/7", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "rename-react-3", + "task": "rename", + "arm": "react", + "n": 3, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "6/6", + "hidden": "16/16", + "overlap": "7/7", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "rename-rx-1", + "task": "rename", + "arm": "rx", + "n": 1, + "checks": 2, + "tests": 2, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "6/6", + "hidden": "16/16", + "overlap": "7/7", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "rename-rx-2", + "task": "rename", + "arm": "rx", + "n": 2, + "checks": 1, + "tests": 2, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "6/6", + "hidden": "16/16", + "overlap": "7/7", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "rename-rx-3", + "task": "rename", + "arm": "rx", + "n": 3, + "checks": 1, + "tests": 2, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "6/6", + "hidden": "16/16", + "overlap": "7/7", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + } +] diff --git a/eval/results/change-2026-10-28/results-small.json b/eval/results/change-2026-10-28/results-small.json new file mode 100644 index 0000000..23fa25d --- /dev/null +++ b/eval/results/change-2026-10-28/results-small.json @@ -0,0 +1,378 @@ +[ + { + "run": "close-react-1", + "task": "close", + "arm": "react", + "n": 1, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "6/6", + "hidden": "15/15", + "overlap": "6/6", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "close-react-2", + "task": "close", + "arm": "react", + "n": 2, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "6/6", + "hidden": "15/15", + "overlap": "6/6", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "close-react-3", + "task": "close", + "arm": "react", + "n": 3, + "checks": 2, + "tests": 1, + "cyclesToGreen": 3, + "checkOk": true, + "built": true, + "visible": "6/6", + "hidden": "15/15", + "overlap": "6/6", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "close-rx-1", + "task": "close", + "arm": "rx", + "n": 1, + "checks": 2, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "6/6", + "hidden": "15/15", + "overlap": "5/6", + "failedOverlap": [ + { + "name": "two closes at once: a declined one does not undo the other", + "error": "expected [issue] rows [\"#3 Crash on save (unassigned)\",\"#4 Typo in footer (dana)\"], got [\"#4 Typo in footer (dana)\"]" + } + ], + "shippedDefect": true, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "close-rx-2", + "task": "close", + "arm": "rx", + "n": 2, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "6/6", + "hidden": "15/15", + "overlap": "6/6", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "close-rx-3", + "task": "close", + "arm": "rx", + "n": 3, + "checks": 2, + "tests": 1, + "cyclesToGreen": 3, + "checkOk": true, + "built": true, + "visible": "6/6", + "hidden": "15/15", + "overlap": "6/6", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "comments-react-1", + "task": "comments", + "arm": "react", + "n": 1, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "6/6", + "hidden": "17/17", + "overlap": "6/6", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "comments-react-2", + "task": "comments", + "arm": "react", + "n": 2, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "6/6", + "hidden": "17/17", + "overlap": "6/6", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "comments-react-3", + "task": "comments", + "arm": "react", + "n": 3, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "6/6", + "hidden": "17/17", + "overlap": "6/6", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "comments-rx-1", + "task": "comments", + "arm": "rx", + "n": 1, + "checks": 2, + "tests": 1, + "cyclesToGreen": 3, + "checkOk": true, + "built": true, + "visible": "6/6", + "hidden": "17/17", + "overlap": "6/6", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [ + { + "name": "a post on one issue does not show on another", + "error": "expected [comments] not to show \"on it\", it shows \"dana: seen it tooann: on it (sending…)\"" + } + ], + "failedVisible": [] + }, + { + "run": "comments-rx-2", + "task": "comments", + "arm": "rx", + "n": 2, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "6/6", + "hidden": "17/17", + "overlap": "4/6", + "failedOverlap": [ + { + "name": "two posts at once: a declined one does not take the other with it", + "error": "expected [comment] rows [\"dana: seen it too\",\"ann: second\"], got [\"dana: seen it too\",\"ann: first (sending…)\",\"ann: second\"]" + }, + { + "name": "comments that load while a post is out keep the post", + "error": "expected [comment] rows [\"dana: seen it too\",\"ann: on it\"], got [\"dana: seen it too\"]" + } + ], + "shippedDefect": true, + "failedHidden": [], + "failedAlongside": [ + { + "name": "a post on one issue does not show on another", + "error": "expected [comments] not to show \"on it\", it shows \"dana: seen it tooann: on it (sending…)\"" + } + ], + "failedVisible": [] + }, + { + "run": "comments-rx-3", + "task": "comments", + "arm": "rx", + "n": 3, + "checks": 2, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "6/6", + "hidden": "17/17", + "overlap": "6/6", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [ + { + "name": "a post on one issue does not show on another", + "error": "expected [comments] not to show \"on it\", it shows \"dana: seen it tooann: on it (sending…)\"" + } + ], + "failedVisible": [] + }, + { + "run": "rename-react-1", + "task": "rename", + "arm": "react", + "n": 1, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "6/6", + "hidden": "16/16", + "overlap": "7/7", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "rename-react-2", + "task": "rename", + "arm": "react", + "n": 2, + "checks": 2, + "tests": 1, + "cyclesToGreen": 3, + "checkOk": true, + "built": true, + "visible": "6/6", + "hidden": "16/16", + "overlap": "7/7", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "rename-react-3", + "task": "rename", + "arm": "react", + "n": 3, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "6/6", + "hidden": "16/16", + "overlap": "7/7", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "rename-rx-1", + "task": "rename", + "arm": "rx", + "n": 1, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "6/6", + "hidden": "16/16", + "overlap": "6/7", + "failedOverlap": [ + { + "name": "an earlier rename declined after a later one was accepted keeps the later title", + "error": "expected [detail] to show \"#3 Crash on load\", it shows \"#3 Crash on savestack trace attachedretryassign to guestrenamerename error: declined\"" + } + ], + "shippedDefect": true, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "rename-rx-2", + "task": "rename", + "arm": "rx", + "n": 2, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "6/6", + "hidden": "16/16", + "overlap": "7/7", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "rename-rx-3", + "task": "rename", + "arm": "rx", + "n": 3, + "checks": 2, + "tests": 2, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "6/6", + "hidden": "16/16", + "overlap": "7/7", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + } +] diff --git a/eval/results/change-2026-10-28/runs/big/close-react-1/App.tsx b/eval/results/change-2026-10-28/runs/big/close-react-1/App.tsx new file mode 100644 index 0000000..e329d48 --- /dev/null +++ b/eval/results/change-2026-10-28/runs/big/close-react-1/App.tsx @@ -0,0 +1,214 @@ +import { useRef, useState } from "react"; +import { QueryClient, QueryClientProvider, useQuery, useQueryClient } from "@tanstack/react-query"; +import { assign, closeIssue, fetchIssue, fetchIssues } from "./api.js"; +import type { Issue } from "./api.js"; + +type Filter = "open" | "closed"; +type Pending = { seq: number; user: string }; +type AssignError = { issueId: number; message: string }; +type CloseError = { issueId: number; message: string }; + +const errorMessage = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +function Tracker() { + const queryClient = useQueryClient(); + const [user, setUser] = useState("guest"); + const [filter, setFilter] = useState("open"); + const [selected, setSelected] = useState(null); + // optimistic assignments still in the air, per issue, oldest first + const [pending, setPending] = useState>({}); + const [assignError, setAssignError] = useState(null); + const seqRef = useRef(0); + // closes still in the air (count per issue) and closes the server accepted + const [closing, setClosing] = useState>({}); + const [closedIds, setClosedIds] = useState([]); + const [closeError, setCloseError] = useState(null); + const closeSeqRef = useRef(0); + const confirmedSeqRef = useRef>({}); + + const list = useQuery({ + queryKey: ["issues", filter], + queryFn: () => fetchIssues(filter), + }); + + const detail = useQuery({ + queryKey: ["issue", selected], + queryFn: async () => { + const id = selected as number; + const d = await fetchIssue(id); + if (d === null) throw new Error(`issue #${id} not found`); + return d; + }, + enabled: selected !== null, + }); + + const assigneeOf = (issue: Issue) => { + const p = pending[issue.id]; + if (p && p.length > 0) return p[p.length - 1].user; + return issue.assignee; + }; + + const select = (id: number) => { + setSelected(id); + setAssignError(null); + setCloseError(null); + }; + + const doClose = async (id: number) => { + const seq = ++closeSeqRef.current; + setCloseError(null); + setClosing(prev => ({ ...prev, [id]: (prev[id] ?? 0) + 1 })); + const done = () => + setClosing(prev => { + const next = { ...prev }; + const n = (next[id] ?? 0) - 1; + if (n > 0) next[id] = n; + else delete next[id]; + return next; + }); + try { + await closeIssue(id); + setClosedIds(prev => (prev.includes(id) ? prev : [...prev, id])); + queryClient.setQueryData(["issues", "open"], old => old?.filter(i => i.id !== id)); + done(); + } catch (e) { + done(); + if (closeSeqRef.current === seq) setCloseError({ issueId: id, message: errorMessage(e) }); + } + }; + + const hidden = (issue: Issue) => + filter === "open" && ((closing[issue.id] ?? 0) > 0 || closedIds.includes(issue.id)); + + const doAssign = async (id: number, who: string) => { + const seq = ++seqRef.current; + setAssignError(null); + setPending(prev => ({ ...prev, [id]: [...(prev[id] ?? []), { seq, user: who }] })); + const done = () => + setPending(prev => { + const rest = (prev[id] ?? []).filter(p => p.seq !== seq); + const next = { ...prev }; + if (rest.length) next[id] = rest; + else delete next[id]; + return next; + }); + try { + const res = await assign(id, who); + if ((confirmedSeqRef.current[id] ?? 0) < seq) { + confirmedSeqRef.current[id] = seq; + queryClient.setQueriesData({ queryKey: ["issues"] }, old => + old?.map(i => (i.id === id ? { ...i, assignee: res.assignee } : i)), + ); + } + done(); + } catch (e) { + done(); + // only report the failure if no later assignment has started since + if (seqRef.current === seq) setAssignError({ issueId: id, message: errorMessage(e) }); + } + }; + + let listContent; + if (list.isPending) listContent =

    loading {filter} issues

    ; + else if (list.isError) listContent =

    list error: {errorMessage(list.error)}

    ; + else + listContent = ( +
      + {list.data.filter(issue => !hidden(issue)).map(issue => ( +
    • + +
    • + ))} +
    + ); + + let detailContent; + if (selected === null) detailContent =

    select an issue

    ; + else { + let body; + if (detail.isFetching || detail.isPending) body =

    loading #{selected}

    ; + else if (detail.isError) body =

    detail error: {errorMessage(detail.error)}

    ; + else + body = ( + <> +

    + #{detail.data.id} {detail.data.title} +

    +

    {detail.data.body}

    + + ); + const id = selected; + detailContent = ( + <> + {body} + + + + {assignError && assignError.issueId === selected && ( +

    {assignError.message}

    + )} + {closeError && closeError.issueId === selected && ( +

    close error: {closeError.message}

    + )} + + ); + } + + return ( +
    +
    + +
    + + +
    +
    {listContent}
    +
    +
    {detailContent}
    +
    + ); +} + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, refetchOnWindowFocus: false, refetchOnReconnect: false }, + }, +}); + +export default function App() { + return ( + + + + ); +} diff --git a/eval/results/change-2026-10-28/runs/big/close-react-1/runlog.jsonl b/eval/results/change-2026-10-28/runs/big/close-react-1/runlog.jsonl new file mode 100644 index 0000000..6078673 --- /dev/null +++ b/eval/results/change-2026-10-28/runs/big/close-react-1/runlog.jsonl @@ -0,0 +1,2 @@ +{"at":"2026-10-28T05:27:51.903Z","cmd":"check","ok":true,"out":"ok"} +{"at":"2026-10-28T05:28:12.504Z","cmd":"test","ok":true,"build":true,"passed":6,"total":6} diff --git a/eval/results/change-2026-10-28/runs/big/close-react-2/App.tsx b/eval/results/change-2026-10-28/runs/big/close-react-2/App.tsx new file mode 100644 index 0000000..2c42b2a --- /dev/null +++ b/eval/results/change-2026-10-28/runs/big/close-react-2/App.tsx @@ -0,0 +1,191 @@ +import { useEffect, useState } from "react"; +import { assign, closeIssue, fetchIssue, fetchIssues } from "./api.js"; +import type { Issue, IssueDetail } from "./api.js"; + +type Filter = "open" | "closed"; +type ListReq = { filter: Filter }; +type ListRes = + | { req: ListReq; startedAt: number; ok: true; data: Issue[] } + | { req: ListReq; startedAt: number; ok: false; error: string }; +type SelReq = { id: number }; +type DetRes = + | { req: SelReq; ok: true; data: IssueDetail | null } + | { req: SelReq; ok: false; error: string }; +type Pending = { seq: number; id: number; user: string }; +type Confirmed = Record; +type AssignState = { seq: number; id: number; error: string | null } | null; + +// Logical clock used to tell whether a list response predates a confirmed assignment. +let clock = 0; +let assignSeq = 0; +let closeSeq = 0; + +type PendingClose = { seq: number; id: number }; +type CloseState = { seq: number; id: number; error: string | null } | null; + +const message = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +export default function App() { + const [user, setUser] = useState("guest"); + const [listReq, setListReq] = useState({ filter: "open" }); + const [listRes, setListRes] = useState(null); + const [sel, setSel] = useState(null); + const [detRes, setDetRes] = useState(null); + const [pending, setPending] = useState([]); + const [confirmed, setConfirmed] = useState({}); + const [assignState, setAssignState] = useState(null); + const [pendingCloses, setPendingCloses] = useState([]); + const [closedIds, setClosedIds] = useState>({}); + const [closeState, setCloseState] = useState(null); + + useEffect(() => { + let cancelled = false; + const startedAt = ++clock; + fetchIssues(listReq.filter).then( + data => { if (!cancelled) setListRes({ req: listReq, startedAt, ok: true, data }); }, + (e: unknown) => { if (!cancelled) setListRes({ req: listReq, startedAt, ok: false, error: message(e) }); }, + ); + return () => { cancelled = true; }; + }, [listReq]); + + useEffect(() => { + if (!sel) return; + let cancelled = false; + fetchIssue(sel.id).then( + data => { if (!cancelled) setDetRes({ req: sel, ok: true, data }); }, + (e: unknown) => { if (!cancelled) setDetRes({ req: sel, ok: false, error: message(e) }); }, + ); + return () => { cancelled = true; }; + }, [sel]); + + const chooseFilter = (filter: Filter) => { + if (filter !== listReq.filter) setListReq({ filter }); + }; + + const select = (id: number) => { + if (sel && sel.id === id) return; + setSel({ id }); + setAssignState(null); + setCloseState(null); + }; + + const retry = () => { + if (sel) setSel({ id: sel.id }); + }; + + const doAssign = () => { + if (!sel) return; + const id = sel.id; + const seq = ++assignSeq; + const who = user; + setPending(p => [...p, { seq, id, user: who }]); + setAssignState({ seq, id, error: null }); + assign(id, who).then( + res => { + const at = ++clock; + setConfirmed(c => ({ ...c, [id]: { assignee: res.assignee, at } })); + setPending(p => p.filter(x => x.seq !== seq)); + }, + (e: unknown) => { + setPending(p => p.filter(x => x.seq !== seq)); + setAssignState(s => (s && s.seq === seq ? { ...s, error: message(e) } : s)); + }, + ); + }; + + const doClose = () => { + if (!sel) return; + const id = sel.id; + const seq = ++closeSeq; + setPendingCloses(p => [...p, { seq, id }]); + setCloseState({ seq, id, error: null }); + closeIssue(id).then( + () => { + setClosedIds(c => ({ ...c, [id]: true })); + setPendingCloses(p => p.filter(x => x.seq !== seq)); + }, + (e: unknown) => { + setPendingCloses(p => p.filter(x => x.seq !== seq)); + setCloseState(s => (s && s.seq === seq ? { ...s, error: message(e) } : s)); + }, + ); + }; + + const hiddenFromOpen = (id: number) => closedIds[id] === true || pendingCloses.some(p => p.id === id); + + const assigneeOf = (row: Issue, startedAt: number): string | null => { + let latest: Pending | null = null; + for (const p of pending) if (p.id === row.id && (!latest || p.seq > latest.seq)) latest = p; + if (latest) return latest.user; + const c = confirmed[row.id]; + if (c && c.at > startedAt) return c.assignee; + return row.assignee; + }; + + const filter = listReq.filter; + let list: React.ReactNode; + if (!listRes || listRes.req !== listReq) list =

    loading {filter} issues

    ; + else if (!listRes.ok) list =

    list error: {listRes.error}

    ; + else + list = ( +
      + {listRes.data.filter(row => filter !== "open" || !hiddenFromOpen(row.id)).map(row => { + const a = assigneeOf(row, listRes.startedAt); + return ( +
    • select(row.id)} + style={{ cursor: "pointer", fontWeight: sel?.id === row.id ? "bold" : undefined }} + > + {`#${row.id} ${row.title} (${a ?? "unassigned"})`} +
    • + ); + })} +
    + ); + + let detail: React.ReactNode; + if (!sel) detail =

    select an issue

    ; + else { + let body: React.ReactNode; + if (!detRes || detRes.req !== sel) body =

    loading #{sel.id}

    ; + else if (!detRes.ok) body =

    detail error: {detRes.error}

    ; + else if (!detRes.data) body =

    detail error: issue #{sel.id} not found

    ; + else + body = ( + <> +

    {`#${detRes.data.id} ${detRes.data.title}`}

    +

    {detRes.data.body}

    + + ); + const assignError = assignState && assignState.id === sel.id ? assignState.error : null; + const closeError = closeState && closeState.id === sel.id ? closeState.error : null; + detail = ( + <> + {body} + {" "} + + + {assignError !== null &&

    {assignError}

    } + {closeError !== null &&

    close error: {closeError}

    } + + ); + } + + return ( +
    + +
    + {" "} + +
    +
    +
    {list}
    +
    {detail}
    +
    +
    + ); +} diff --git a/eval/results/change-2026-10-28/runs/big/close-react-2/runlog.jsonl b/eval/results/change-2026-10-28/runs/big/close-react-2/runlog.jsonl new file mode 100644 index 0000000..39bb27a --- /dev/null +++ b/eval/results/change-2026-10-28/runs/big/close-react-2/runlog.jsonl @@ -0,0 +1,2 @@ +{"at":"2026-10-28T05:27:36.040Z","cmd":"check","ok":true,"out":"ok"} +{"at":"2026-10-28T05:27:48.858Z","cmd":"test","ok":true,"build":true,"passed":6,"total":6} diff --git a/eval/results/change-2026-10-28/runs/big/close-react-3/App.tsx b/eval/results/change-2026-10-28/runs/big/close-react-3/App.tsx new file mode 100644 index 0000000..ffef0b6 --- /dev/null +++ b/eval/results/change-2026-10-28/runs/big/close-react-3/App.tsx @@ -0,0 +1,205 @@ +import { useEffect, useRef, useState } from "react"; +import { assign, closeIssue, fetchIssue, fetchIssues } from "./api.js"; +import type { Issue, IssueDetail } from "./api.js"; + +type Filter = "open" | "closed"; + +type ListResult = + | { filter: Filter; nonce: number; ok: true; issues: Issue[] } + | { filter: Filter; nonce: number; ok: false; message: string }; + +type DetailResult = + | { id: number; nonce: number; ok: true; issue: IssueDetail | null } + | { id: number; nonce: number; ok: false; message: string }; + +type Pending = { token: number; assignee: string; confirmed: boolean }; + +const errMessage = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +export default function App() { + const [user, setUser] = useState("guest"); + const [filter, setFilter] = useState("open"); + const [listNonce, setListNonce] = useState(0); + const [list, setList] = useState(null); + + const [selectedId, setSelectedId] = useState(null); + const [detailNonce, setDetailNonce] = useState(0); + const [detail, setDetail] = useState(null); + + // Per-issue assignments, oldest first; the latest still standing wins. + const [assignments, setAssignments] = useState>({}); + const [assignError, setAssignError] = useState<{ id: number; token: number; message: string } | null>(null); + const tokenRef = useRef(0); + const latestAssignRef = useRef(0); + const selectedRef = useRef(null); + + // Per-issue closes in flight or accepted; any standing close hides the row from the open list. + const [closes, setCloses] = useState>({}); + const [closeError, setCloseError] = useState<{ id: number; token: number; message: string } | null>(null); + const latestCloseRef = useRef(0); + + useEffect(() => { + let live = true; + fetchIssues(filter).then( + issues => { if (live) setList({ filter, nonce: listNonce, ok: true, issues }); }, + (e: unknown) => { if (live) setList({ filter, nonce: listNonce, ok: false, message: errMessage(e) }); }, + ); + return () => { live = false; }; + }, [filter, listNonce]); + + useEffect(() => { + if (selectedId === null) return; + let live = true; + const id = selectedId; + fetchIssue(id).then( + issue => { if (live) setDetail({ id, nonce: detailNonce, ok: true, issue }); }, + (e: unknown) => { if (live) setDetail({ id, nonce: detailNonce, ok: false, message: errMessage(e) }); }, + ); + return () => { live = false; }; + }, [selectedId, detailNonce]); + + const chooseFilter = (f: Filter) => { + if (f === filter) return; + setFilter(f); + setListNonce(n => n + 1); + }; + + const select = (id: number) => { + if (id === selectedId) return; + selectedRef.current = id; + setSelectedId(id); + setDetailNonce(n => n + 1); + setAssignError(null); + setCloseError(null); + }; + + const retry = () => { + setDetailNonce(n => n + 1); + }; + + const doAssign = () => { + if (selectedId === null) return; + const id = selectedId; + const who = user; + const token = ++tokenRef.current; + latestAssignRef.current = token; + setAssignError(null); + setAssignments(prev => ({ ...prev, [id]: [...(prev[id] ?? []), { token, assignee: who, confirmed: false }] })); + assign(id, who).then( + res => { + setAssignments(prev => { + const entries = prev[id] ?? []; + // A confirmed assignment supersedes older ones. + const idx = entries.findIndex(p => p.token === token); + if (idx < 0) return prev; + const next = entries.slice(idx).map(p => (p.token === token ? { ...p, assignee: res.assignee, confirmed: true } : p)); + return { ...prev, [id]: next }; + }); + }, + (e: unknown) => { + setAssignments(prev => { + const entries = (prev[id] ?? []).filter(p => p.token !== token); + return { ...prev, [id]: entries }; + }); + if (latestAssignRef.current === token && selectedRef.current === id) { + setAssignError({ id, token, message: errMessage(e) }); + } + }, + ); + }; + + const doClose = () => { + if (selectedId === null) return; + const id = selectedId; + const token = ++tokenRef.current; + latestCloseRef.current = token; + setCloseError(null); + setCloses(prev => ({ ...prev, [id]: [...(prev[id] ?? []), { token, done: false }] })); + closeIssue(id).then( + () => { + setCloses(prev => ({ + ...prev, + [id]: (prev[id] ?? []).map(c => (c.token === token ? { ...c, done: true } : c)), + })); + }, + (e: unknown) => { + setCloses(prev => ({ ...prev, [id]: (prev[id] ?? []).filter(c => c.token !== token) })); + if (latestCloseRef.current === token && selectedRef.current === id) { + setCloseError({ id, token, message: errMessage(e) }); + } + }, + ); + }; + + const isClosed = (id: number) => (closes[id]?.length ?? 0) > 0; + + const shownAssignee = (issue: Issue): string | null => { + const entries = assignments[issue.id]; + if (entries && entries.length) return entries[entries.length - 1].assignee; + return issue.assignee; + }; + + const listCurrent = list !== null && list.filter === filter && list.nonce === listNonce ? list : null; + + let listBody; + if (!listCurrent) listBody =

    loading {filter} issues

    ; + else if (!listCurrent.ok) listBody =

    list error: {listCurrent.message}

    ; + else + listBody = ( +
      + {listCurrent.issues.filter(issue => !(listCurrent.filter === "open" && isClosed(issue.id))).map(issue => ( +
    • select(issue.id)} + style={{ cursor: "pointer", fontWeight: issue.id === selectedId ? "bold" : undefined }} + > + {`#${issue.id} ${issue.title} (${shownAssignee(issue) ?? "unassigned"})`} +
    • + ))} +
    + ); + + let detailBody; + if (selectedId === null) detailBody =

    select an issue

    ; + else { + const current = detail !== null && detail.id === selectedId && detail.nonce === detailNonce ? detail : null; + let content; + if (!current) content =

    loading #{selectedId}

    ; + else if (!current.ok) content =

    detail error: {current.message}

    ; + else if (current.issue === null) content =

    detail error: issue #{selectedId} not found

    ; + else + content = ( +
    +

    {`#${current.issue.id} ${current.issue.title}`}

    +

    {current.issue.body}

    +
    + ); + detailBody = ( + <> + {content} + + + + {assignError && assignError.id === selectedId &&

    {assignError.message}

    } + {closeError && closeError.id === selectedId &&

    {`close error: ${closeError.message}`}

    } + + ); + } + + return ( +
    + +
    + + +
    +
    +
    {listBody}
    +
    {detailBody}
    +
    +
    + ); +} diff --git a/eval/results/change-2026-10-28/runs/big/close-react-3/runlog.jsonl b/eval/results/change-2026-10-28/runs/big/close-react-3/runlog.jsonl new file mode 100644 index 0000000..9b9b150 --- /dev/null +++ b/eval/results/change-2026-10-28/runs/big/close-react-3/runlog.jsonl @@ -0,0 +1,2 @@ +{"at":"2026-10-28T05:27:38.837Z","cmd":"check","ok":true,"out":"ok"} +{"at":"2026-10-28T05:27:53.478Z","cmd":"test","ok":true,"build":true,"passed":6,"total":6} diff --git a/eval/results/change-2026-10-28/runs/big/close-rx-1/main.rx b/eval/results/change-2026-10-28/runs/big/close-rx-1/main.rx new file mode 100644 index 0000000..a22fabd --- /dev/null +++ b/eval/results/change-2026-10-28/runs/big/close-rx-1/main.rx @@ -0,0 +1,93 @@ +import pure { fetchIssues, fetchIssue } from "./api" +import { assign, closeIssue } from "./api" +import pure { indexOf, filter as keep } from "./prelude" + +export error NotFound(id: number) => `issue #${id} not found` + +fn found(it, id: number) => it == null ? throw NotFound(id) : it + +export default component App() { + source user: string := "guest" + source filter: string := "open" + source selected: number | null := null + source issues key id = await fetchIssues(filter) + source assignError: string = "" + source closeError: string = "" + source hidden: { [id: number]: boolean } = {} + + action assignTo(id: number, who: string) { + optimistic assignError := "" + try { + let i = indexOf(issues, t => t.id == id) + optimistic issues[i].assignee := who + await assign(id, who) + refresh issues + } catch (e) { + assignError := e.message + } + } + + action close(id: number) { + optimistic closeError := "" + try { + optimistic hidden[id] := true + await closeIssue(id) + refresh issues + await until(() => indexOf(issues, t => t.id == id) == -1) + } catch (e) { + closeError := `close error: ${e.message}` + } + } + +
    +
    + +
    +
    + + +
    +
    + {loading on(filter) fallback {

    loading {latest filter} issues

    } { + {errored fallback(e) {

    list error: {e.message}

    } { +
      + {for it in keep(issues, t => !hidden[t.id]) { +
    • { + selected := it.id + onFlush(() => { + assignError := "" + closeError := "" + }) + }}>#{it.id} {it.title} ({it.assignee ?? "unassigned"})
    • + }} +
    + }} + }} +
    +
    + {if selected != null { + detail = found(await fetchIssue(selected), selected) + {loading on(selected) fallback {

    loading #{latest selected}

    } { + {errored fallback(e) {

    detail error: {e.message}

    } { +

    #{detail.id} {detail.title}

    +

    {detail.body}

    + }} + + }} + + + {if closeError != "" { +

    {closeError}

    + }} + {if assignError != "" { +

    {assignError}

    + }} + } else { +

    select an issue

    + }} +
    +
    +} diff --git a/eval/results/change-2026-10-28/runs/big/close-rx-1/runlog.jsonl b/eval/results/change-2026-10-28/runs/big/close-rx-1/runlog.jsonl new file mode 100644 index 0000000..5c63a5f --- /dev/null +++ b/eval/results/change-2026-10-28/runs/big/close-rx-1/runlog.jsonl @@ -0,0 +1,4 @@ +{"at":"2026-10-28T05:28:18.955Z","cmd":"check","ok":false,"out":"/home/claude/runs/change-big/close-rx-1/src/main.rx:11: error SHADOWED_IMPORT [MO-1d] filter shadows the imported filter: rename one (`import { filter as … }`)"} +{"at":"2026-10-28T05:28:22.471Z","cmd":"check","ok":true,"out":"src/main.rx: ok"} +{"at":"2026-10-28T05:28:34.039Z","cmd":"test","ok":true,"build":true,"passed":6,"total":6} +{"at":"2026-10-28T05:28:51.944Z","cmd":"test","ok":true,"build":true,"passed":8,"total":8} diff --git a/eval/results/change-2026-10-28/runs/big/close-rx-2/main.rx b/eval/results/change-2026-10-28/runs/big/close-rx-2/main.rx new file mode 100644 index 0000000..663d5e5 --- /dev/null +++ b/eval/results/change-2026-10-28/runs/big/close-rx-2/main.rx @@ -0,0 +1,96 @@ +import pure { fetchIssues, fetchIssue } from "./api" +import { assign, closeIssue } from "./api" +import pure { indexOf, map, len, filter as keep } from "./prelude" + +export error NotFound(id: number) => `issue #${id} not found` +fn found(it, id: number) => it == null ? throw NotFound(id) : it + +type AssignError = { id: number; message: string } +type RowState = { id: number; closed: boolean } + +export default component App() { + source user: string := "guest" + source filter: string := "open" + source selectedId: number | null := null + source assignError: AssignError | null := null + source closeError: AssignError | null := null + source issues key id = await fetchIssues(filter) + source rowStates: RowState[] key id = map(issues, it => ({ id: it.id, closed: false })) + shown = keep(issues, it => len(keep(rowStates, r => r.id == it.id && r.closed)) == 0) + + detail = selectedId == null ? null : found(await fetchIssue(selectedId), selectedId) + + action assignTo(id: number, who: string) { + try { + let i = indexOf(issues, t => t.id == id) + optimistic issues[i].assignee := who + await assign(id, who) + refresh issues + } catch (e) { + assignError := { id: id, message: e.message } + } + } + + action closeOne(id: number) { + try { + let i = indexOf(rowStates, r => r.id == id) + optimistic rowStates[i].closed := true + await closeIssue(id) + refresh issues + } catch (e) { + closeError := { id: id, message: e.message } + } + } + +
    + +
    + + +
    +
      + {loading on(filter) fallback {
    • loading {latest filter} issues
    • } { + {errored fallback(e) {
    • list error: {e.message}
    • } { + {for it in shown { +
    • { + onFlush(() => { assignError := null; closeError := null }) + selectedId := it.id + }}>#{it.id} {it.title} ({it.assignee ?? "unassigned"})
    • + }} + }} + }} +
    +
    + {if selectedId == null { +

    select an issue

    + } else { + {loading on(selectedId) fallback {

    loading #{latest selectedId}

    } { + {errored fallback(e) {

    detail error: {e.message}

    } { + {if detail { +

    #{detail.id} {detail.title}

    +

    {detail.body}

    + }} + }} + }} + + + + {if closeError != null && closeError.id == selectedId { +

    close error: {closeError.message}

    + }} + {if assignError != null && assignError.id == selectedId { +

    {assignError.message}

    + }} + }} +
    +
    +} diff --git a/eval/results/change-2026-10-28/runs/big/close-rx-2/runlog.jsonl b/eval/results/change-2026-10-28/runs/big/close-rx-2/runlog.jsonl new file mode 100644 index 0000000..3763ae5 --- /dev/null +++ b/eval/results/change-2026-10-28/runs/big/close-rx-2/runlog.jsonl @@ -0,0 +1,5 @@ +{"at":"2026-10-28T05:27:51.988Z","cmd":"check","ok":false,"out":"/home/claude/runs/change-big/close-rx-2/src/main.rx:13: error SHADOWED_IMPORT [MO-1d] filter shadows the imported filter: rename one (`import { filter as … }`)\n/home/claude/runs/change-big/close-rx-2/src/main.rx:19: error REACTIVE_WRITE_IN_OWNED_SCOPE [W-2] a statement-bodied closure in reactive scope (SC-3: a closure created here is a pure function)"} +{"at":"2026-10-28T05:27:58.148Z","cmd":"check","ok":true,"out":"src/main.rx: ok"} +{"at":"2026-10-28T05:28:19.010Z","cmd":"test","ok":true,"build":true,"passed":6,"total":6} +{"at":"2026-10-28T05:28:42.093Z","cmd":"test","ok":true,"build":true,"passed":8,"total":8} +{"at":"2026-10-28T05:28:45.787Z","cmd":"check","ok":true,"out":"src/main.rx: ok"} diff --git a/eval/results/change-2026-10-28/runs/big/close-rx-3/main.rx b/eval/results/change-2026-10-28/runs/big/close-rx-3/main.rx new file mode 100644 index 0000000..9d3c632 --- /dev/null +++ b/eval/results/change-2026-10-28/runs/big/close-rx-3/main.rx @@ -0,0 +1,93 @@ +import pure { fetchIssues, fetchIssue } from "./api" +import { assign, closeIssue } from "./api" +import pure { indexOf, filter as keep } from "./prelude" + +export error NotFound(id: number) => `issue #${id} not found` + +fn found(it, id) => it == null ? throw NotFound(id) : it + +export default component App() { + source user: string := "guest" + source filter: string := "open" + source selected: number | null := null + source issues key id = await fetchIssues(filter) + source assignError: { id: number; message: string } | null := null + source closeError: { id: number; message: string } | null := null + source closing: number[] = [] + + detail = selected == null ? null : found(await fetchIssue(selected), selected) + shownCloseError = closeError != null && closeError.id == selected ? closeError.message : "" + openRows = keep(issues, t => indexOf(closing, c => c == t.id) < 0) + shownError = assignError != null && assignError.id == selected ? assignError.message : "" + + action assignTo(id: number, who: string) { + try { + let i = indexOf(issues, t => t.id == id) + if (i >= 0) { + optimistic issues[i].assignee := who + } + await assign(id, who) + refresh issues + } catch (e) { + assignError := { id: id, message: e.message } + } + } + + action closeOne(id: number) { + try { + optimistic closing := [...closing, id] + await closeIssue(id) + refresh issues + } catch (e) { + closeError := { id: id, message: e.message } + } + } + +
    + +
    + + +
    +
    +
      + {loading on(filter) fallback {
    • loading {filter} issues
    • } { + {errored fallback(e) {
    • list error: {e.message}
    • } { + {for it in openRows { +
    • { + selected := it.id + onFlush(() => { + assignError := null + closeError := null + }) + }}>#{it.id} {it.title} ({it.assignee ?? "unassigned"})
    • + }} + }} + }} +
    +
    + {if selected == null { +

    select an issue

    + } else { + {loading on(selected) fallback {

    loading #{selected}

    } { + {errored fallback(e) {

    detail error: {e.message}

    } { +

    #{detail.id} {detail.title}

    +

    {detail.body}

    + }} + }} + + + + {if shownError != "" {

    {shownError}

    }} + {if shownCloseError != "" {

    close error: {shownCloseError}

    }} + }} +
    +
    +
    +} diff --git a/eval/results/change-2026-10-28/runs/big/close-rx-3/runlog.jsonl b/eval/results/change-2026-10-28/runs/big/close-rx-3/runlog.jsonl new file mode 100644 index 0000000..8e12240 --- /dev/null +++ b/eval/results/change-2026-10-28/runs/big/close-rx-3/runlog.jsonl @@ -0,0 +1,3 @@ +{"at":"2026-10-28T05:27:49.488Z","cmd":"check","ok":false,"out":"/home/claude/runs/change-big/close-rx-3/src/main.rx:11: error SHADOWED_IMPORT [MO-1d] filter shadows the imported filter: rename one (`import { filter as … }`)"} +{"at":"2026-10-28T05:27:54.499Z","cmd":"check","ok":true,"out":"src/main.rx: ok"} +{"at":"2026-10-28T05:28:17.803Z","cmd":"test","ok":true,"build":true,"passed":6,"total":6} diff --git a/eval/results/change-2026-10-28/runs/big/comments-react-1/App.tsx b/eval/results/change-2026-10-28/runs/big/comments-react-1/App.tsx new file mode 100644 index 0000000..f19c9d5 --- /dev/null +++ b/eval/results/change-2026-10-28/runs/big/comments-react-1/App.tsx @@ -0,0 +1,248 @@ +import { useRef, useState } from "react"; +import { QueryClient, QueryClientProvider, useQuery, useQueryClient } from "@tanstack/react-query"; +import { addComment, assign, fetchComments, fetchIssue, fetchIssues } from "./api.js"; +import type { Comment, Issue } from "./api.js"; + +type Filter = "open" | "closed"; +type Pending = { seq: number; user: string }; +type AssignError = { issueId: number; message: string }; +type PendingComment = { seq: number; author: string; text: string }; + +const errorMessage = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +function Tracker() { + const queryClient = useQueryClient(); + const [user, setUser] = useState("guest"); + const [filter, setFilter] = useState("open"); + const [selected, setSelected] = useState(null); + // optimistic assignments still in the air, per issue, oldest first + const [pending, setPending] = useState>({}); + const [assignError, setAssignError] = useState(null); + const seqRef = useRef(0); + const confirmedSeqRef = useRef>({}); + const [commentText, setCommentText] = useState(""); + // optimistic comments still in the air, per issue, oldest first + const [pendingComments, setPendingComments] = useState>({}); + const [postError, setPostError] = useState(null); + const postSeqRef = useRef(0); + const selectedRef = useRef(null); + + const list = useQuery({ + queryKey: ["issues", filter], + queryFn: () => fetchIssues(filter), + }); + + const detail = useQuery({ + queryKey: ["issue", selected], + queryFn: async () => { + const id = selected as number; + const d = await fetchIssue(id); + if (d === null) throw new Error(`issue #${id} not found`); + return d; + }, + enabled: selected !== null, + }); + + const comments = useQuery({ + queryKey: ["comments", selected], + queryFn: () => fetchComments(selected as number), + enabled: selected !== null, + }); + + const assigneeOf = (issue: Issue) => { + const p = pending[issue.id]; + if (p && p.length > 0) return p[p.length - 1].user; + return issue.assignee; + }; + + const select = (id: number) => { + setSelected(id); + selectedRef.current = id; + setAssignError(null); + setPostError(null); + }; + + const doPost = async (id: number, author: string, text: string) => { + const seq = ++postSeqRef.current; + setPostError(null); + setPendingComments(prev => ({ ...prev, [id]: [...(prev[id] ?? []), { seq, author, text }] })); + const done = () => + setPendingComments(prev => { + const rest = (prev[id] ?? []).filter(p => p.seq !== seq); + const next = { ...prev }; + if (rest.length) next[id] = rest; + else delete next[id]; + return next; + }); + try { + const res = await addComment(id, author, text); + queryClient.setQueryData(["comments", id], old => (old ? [...old, res] : old)); + done(); + } catch (e) { + done(); + if (selectedRef.current === id) setPostError({ issueId: id, message: errorMessage(e) }); + } + }; + + const doAssign = async (id: number, who: string) => { + const seq = ++seqRef.current; + setAssignError(null); + setPending(prev => ({ ...prev, [id]: [...(prev[id] ?? []), { seq, user: who }] })); + const done = () => + setPending(prev => { + const rest = (prev[id] ?? []).filter(p => p.seq !== seq); + const next = { ...prev }; + if (rest.length) next[id] = rest; + else delete next[id]; + return next; + }); + try { + const res = await assign(id, who); + if ((confirmedSeqRef.current[id] ?? 0) < seq) { + confirmedSeqRef.current[id] = seq; + queryClient.setQueriesData({ queryKey: ["issues"] }, old => + old?.map(i => (i.id === id ? { ...i, assignee: res.assignee } : i)), + ); + } + done(); + } catch (e) { + done(); + // only report the failure if no later assignment has started since + if (seqRef.current === seq) setAssignError({ issueId: id, message: errorMessage(e) }); + } + }; + + let listContent; + if (list.isPending) listContent =

    loading {filter} issues

    ; + else if (list.isError) listContent =

    list error: {errorMessage(list.error)}

    ; + else + listContent = ( +
      + {list.data.map(issue => ( +
    • + +
    • + ))} +
    + ); + + let detailContent; + if (selected === null) detailContent =

    select an issue

    ; + else { + let body; + if (detail.isFetching || detail.isPending) body =

    loading #{selected}

    ; + else if (detail.isError) body =

    detail error: {errorMessage(detail.error)}

    ; + else + body = ( + <> +

    + #{detail.data.id} {detail.data.title} +

    +

    {detail.data.body}

    + + ); + const id = selected; + const commentsContent = ( + <> + {comments.isPending &&

    loading comments

    } + {comments.isError &&

    comments error: {errorMessage(comments.error)}

    } +
      + {comments.isSuccess && + comments.data.map((c, i) => ( +
    • + {c.author}: {c.text} +
    • + ))} + {(pendingComments[id] ?? []).map(p => ( +
    • + {p.author}: {p.text} (sending…) +
    • + ))} +
    + + ); + const submitPost = () => { + if (commentText === "") return; + setCommentText(""); + void doPost(id, user, commentText); + }; + detailContent = ( + <> + {body} + + + {assignError && assignError.issueId === selected && ( +

    {assignError.message}

    + )} +
    {commentsContent}
    + setCommentText(e.target.value)} + /> + + {postError && postError.issueId === selected && ( +

    post error: {postError.message}

    + )} + + ); + } + + return ( +
    +
    + +
    + + +
    +
    {listContent}
    +
    +
    {detailContent}
    +
    + ); +} + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, refetchOnWindowFocus: false, refetchOnReconnect: false }, + }, +}); + +export default function App() { + return ( + + + + ); +} diff --git a/eval/results/change-2026-10-28/runs/big/comments-react-1/runlog.jsonl b/eval/results/change-2026-10-28/runs/big/comments-react-1/runlog.jsonl new file mode 100644 index 0000000..da26532 --- /dev/null +++ b/eval/results/change-2026-10-28/runs/big/comments-react-1/runlog.jsonl @@ -0,0 +1,4 @@ +{"at":"2026-10-28T05:28:04.319Z","cmd":"check","ok":true,"out":"ok"} +{"at":"2026-10-28T05:28:21.424Z","cmd":"test","ok":true,"build":true,"passed":6,"total":6} +{"at":"2026-10-28T05:28:33.003Z","cmd":"check","ok":true,"out":"ok"} +{"at":"2026-10-28T05:28:41.938Z","cmd":"test","ok":true,"build":true,"passed":6,"total":6} diff --git a/eval/results/change-2026-10-28/runs/big/comments-react-2/App.tsx b/eval/results/change-2026-10-28/runs/big/comments-react-2/App.tsx new file mode 100644 index 0000000..711d6b7 --- /dev/null +++ b/eval/results/change-2026-10-28/runs/big/comments-react-2/App.tsx @@ -0,0 +1,235 @@ +import { useEffect, useState } from "react"; +import { addComment, assign, fetchComments, fetchIssue, fetchIssues } from "./api.js"; +import type { Comment, Issue, IssueDetail } from "./api.js"; + +type Filter = "open" | "closed"; +type ListReq = { filter: Filter }; +type ListRes = + | { req: ListReq; startedAt: number; ok: true; data: Issue[] } + | { req: ListReq; startedAt: number; ok: false; error: string }; +type SelReq = { id: number }; +type DetRes = + | { req: SelReq; ok: true; data: IssueDetail | null } + | { req: SelReq; ok: false; error: string }; +type Pending = { seq: number; id: number; user: string }; +type Confirmed = Record; +type AssignState = { seq: number; id: number; error: string | null } | null; + +type ComRes = + | { id: number; startedAt: number; ok: true; data: Comment[] } + | { id: number; startedAt: number; ok: false; error: string }; +type LocalPost = { seq: number; id: number; author: string; text: string; at: number | null }; +type PostState = { seq: number; id: number; error: string | null } | null; + +// Logical clock used to tell whether a list response predates a confirmed assignment. +let clock = 0; +let assignSeq = 0; +let postSeq = 0; + +const message = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +export default function App() { + const [user, setUser] = useState("guest"); + const [listReq, setListReq] = useState({ filter: "open" }); + const [listRes, setListRes] = useState(null); + const [sel, setSel] = useState(null); + const [detRes, setDetRes] = useState(null); + const [pending, setPending] = useState([]); + const [confirmed, setConfirmed] = useState({}); + const [assignState, setAssignState] = useState(null); + const [comRes, setComRes] = useState(null); + const [posts, setPosts] = useState([]); + const [postState, setPostState] = useState(null); + const [draft, setDraft] = useState(""); + + const selId = sel ? sel.id : null; + useEffect(() => { + if (selId === null) return; + let cancelled = false; + const startedAt = ++clock; + fetchComments(selId).then( + data => { if (!cancelled) setComRes({ id: selId, startedAt, ok: true, data }); }, + (e: unknown) => { if (!cancelled) setComRes({ id: selId, startedAt, ok: false, error: message(e) }); }, + ); + return () => { + cancelled = true; + setComRes(null); + }; + }, [selId]); + + useEffect(() => { + let cancelled = false; + const startedAt = ++clock; + fetchIssues(listReq.filter).then( + data => { if (!cancelled) setListRes({ req: listReq, startedAt, ok: true, data }); }, + (e: unknown) => { if (!cancelled) setListRes({ req: listReq, startedAt, ok: false, error: message(e) }); }, + ); + return () => { cancelled = true; }; + }, [listReq]); + + useEffect(() => { + if (!sel) return; + let cancelled = false; + fetchIssue(sel.id).then( + data => { if (!cancelled) setDetRes({ req: sel, ok: true, data }); }, + (e: unknown) => { if (!cancelled) setDetRes({ req: sel, ok: false, error: message(e) }); }, + ); + return () => { cancelled = true; }; + }, [sel]); + + const chooseFilter = (filter: Filter) => { + if (filter !== listReq.filter) setListReq({ filter }); + }; + + const select = (id: number) => { + if (sel && sel.id === id) return; + setSel({ id }); + setAssignState(null); + setPostState(null); + }; + + const doPost = () => { + if (!sel) return; + const text = draft; + if (text.trim() === "") return; + const id = sel.id; + const seq = ++postSeq; + const author = user; + setDraft(""); + setPosts(p => [...p, { seq, id, author, text, at: null }]); + setPostState({ seq, id, error: null }); + addComment(id, author, text).then( + res => { + const at = ++clock; + setPosts(p => p.map(x => (x.seq === seq ? { ...x, author: res.author, text: res.text, at } : x))); + }, + (e: unknown) => { + setPosts(p => p.filter(x => x.seq !== seq)); + setPostState(s => (s && s.seq === seq ? { ...s, error: message(e) } : s)); + }, + ); + }; + + const retry = () => { + if (sel) setSel({ id: sel.id }); + }; + + const doAssign = () => { + if (!sel) return; + const id = sel.id; + const seq = ++assignSeq; + const who = user; + setPending(p => [...p, { seq, id, user: who }]); + setAssignState({ seq, id, error: null }); + assign(id, who).then( + res => { + const at = ++clock; + setConfirmed(c => ({ ...c, [id]: { assignee: res.assignee, at } })); + setPending(p => p.filter(x => x.seq !== seq)); + }, + (e: unknown) => { + setPending(p => p.filter(x => x.seq !== seq)); + setAssignState(s => (s && s.seq === seq ? { ...s, error: message(e) } : s)); + }, + ); + }; + + const assigneeOf = (row: Issue, startedAt: number): string | null => { + let latest: Pending | null = null; + for (const p of pending) if (p.id === row.id && (!latest || p.seq > latest.seq)) latest = p; + if (latest) return latest.user; + const c = confirmed[row.id]; + if (c && c.at > startedAt) return c.assignee; + return row.assignee; + }; + + const filter = listReq.filter; + let list: React.ReactNode; + if (!listRes || listRes.req !== listReq) list =

    loading {filter} issues

    ; + else if (!listRes.ok) list =

    list error: {listRes.error}

    ; + else + list = ( +
      + {listRes.data.map(row => { + const a = assigneeOf(row, listRes.startedAt); + return ( +
    • select(row.id)} + style={{ cursor: "pointer", fontWeight: sel?.id === row.id ? "bold" : undefined }} + > + {`#${row.id} ${row.title} (${a ?? "unassigned"})`} +
    • + ); + })} +
    + ); + + let detail: React.ReactNode; + if (!sel) detail =

    select an issue

    ; + else { + let body: React.ReactNode; + if (!detRes || detRes.req !== sel) body =

    loading #{sel.id}

    ; + else if (!detRes.ok) body =

    detail error: {detRes.error}

    ; + else if (!detRes.data) body =

    detail error: issue #{sel.id} not found

    ; + else + body = ( + <> +

    {`#${detRes.data.id} ${detRes.data.title}`}

    +

    {detRes.data.body}

    + + ); + const assignError = assignState && assignState.id === sel.id ? assignState.error : null; + const res = comRes && comRes.id === sel.id ? comRes : null; + const local = posts.filter( + p => p.id === sel.id && (p.at === null || !res || p.at > res.startedAt), + ); + const postError = postState && postState.id === sel.id ? postState.error : null; + const comments = ( +
    + {!res ?

    loading comments

    : !res.ok ?

    comments error: {res.error}

    : null} +
      + {res && res.ok && res.data.map((c, i) => ( +
    • {`${c.author}: ${c.text}`}
    • + ))} + {local.map(p => ( +
    • + {`${p.author}: ${p.text}${p.at === null ? " (sending…)" : ""}`} +
    • + ))} +
    +
    + ); + detail = ( + <> + {body} + {" "} + + {assignError !== null &&

    {assignError}

    } + {comments} +
    + setDraft(e.target.value)} />{" "} + +
    + {postError !== null &&

    post error: {postError}

    } + + ); + } + + return ( +
    + +
    + {" "} + +
    +
    +
    {list}
    +
    {detail}
    +
    +
    + ); +} diff --git a/eval/results/change-2026-10-28/runs/big/comments-react-2/runlog.jsonl b/eval/results/change-2026-10-28/runs/big/comments-react-2/runlog.jsonl new file mode 100644 index 0000000..c06a5a5 --- /dev/null +++ b/eval/results/change-2026-10-28/runs/big/comments-react-2/runlog.jsonl @@ -0,0 +1,2 @@ +{"at":"2026-10-28T05:28:08.073Z","cmd":"check","ok":true,"out":"ok"} +{"at":"2026-10-28T05:28:21.830Z","cmd":"test","ok":true,"build":true,"passed":6,"total":6} diff --git a/eval/results/change-2026-10-28/runs/big/comments-react-3/App.tsx b/eval/results/change-2026-10-28/runs/big/comments-react-3/App.tsx new file mode 100644 index 0000000..788bd9a --- /dev/null +++ b/eval/results/change-2026-10-28/runs/big/comments-react-3/App.tsx @@ -0,0 +1,268 @@ +import { useEffect, useRef, useState } from "react"; +import { addComment, assign, fetchComments, fetchIssue, fetchIssues } from "./api.js"; +import type { Comment, Issue, IssueDetail } from "./api.js"; + +type Filter = "open" | "closed"; + +type ListResult = + | { filter: Filter; nonce: number; ok: true; issues: Issue[] } + | { filter: Filter; nonce: number; ok: false; message: string }; + +type DetailResult = + | { id: number; nonce: number; ok: true; issue: IssueDetail | null } + | { id: number; nonce: number; ok: false; message: string }; + +type CommentsResult = + | { id: number; nonce: number; since: number; ok: true; comments: Comment[] } + | { id: number; nonce: number; since: number; ok: false; message: string }; + +// A comment posted from this page: `confirmedAt` is set once the server accepts it. +type LocalComment = { token: number; author: string; text: string; confirmedAt: number | null }; + +type Pending = { token: number; assignee: string; confirmed: boolean }; + +const errMessage = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +export default function App() { + const [user, setUser] = useState("guest"); + const [filter, setFilter] = useState("open"); + const [listNonce, setListNonce] = useState(0); + const [list, setList] = useState(null); + + const [selectedId, setSelectedId] = useState(null); + const [detailNonce, setDetailNonce] = useState(0); + const [detail, setDetail] = useState(null); + + // Per-issue assignments, oldest first; the latest still standing wins. + const [assignments, setAssignments] = useState>({}); + const [assignError, setAssignError] = useState<{ id: number; token: number; message: string } | null>(null); + const tokenRef = useRef(0); + const latestAssignRef = useRef(0); + const selectedRef = useRef(null); + + const [commentsNonce, setCommentsNonce] = useState(0); + const [comments, setComments] = useState(null); + const [localComments, setLocalComments] = useState>({}); + const [commentText, setCommentText] = useState(""); + const [postError, setPostError] = useState<{ id: number; message: string } | null>(null); + const confirmSeqRef = useRef(0); + const latestPostRef = useRef(0); + + useEffect(() => { + let live = true; + fetchIssues(filter).then( + issues => { if (live) setList({ filter, nonce: listNonce, ok: true, issues }); }, + (e: unknown) => { if (live) setList({ filter, nonce: listNonce, ok: false, message: errMessage(e) }); }, + ); + return () => { live = false; }; + }, [filter, listNonce]); + + useEffect(() => { + if (selectedId === null) return; + let live = true; + const id = selectedId; + fetchIssue(id).then( + issue => { if (live) setDetail({ id, nonce: detailNonce, ok: true, issue }); }, + (e: unknown) => { if (live) setDetail({ id, nonce: detailNonce, ok: false, message: errMessage(e) }); }, + ); + return () => { live = false; }; + }, [selectedId, detailNonce]); + + useEffect(() => { + if (selectedId === null) return; + let live = true; + const id = selectedId; + const nonce = commentsNonce; + // Local comments confirmed before this fetch started are part of its answer. + const since = confirmSeqRef.current; + fetchComments(id).then( + list => { if (live) setComments({ id, nonce, since, ok: true, comments: list }); }, + (e: unknown) => { if (live) setComments({ id, nonce, since, ok: false, message: errMessage(e) }); }, + ); + return () => { live = false; }; + }, [selectedId, commentsNonce]); + + const chooseFilter = (f: Filter) => { + if (f === filter) return; + setFilter(f); + setListNonce(n => n + 1); + }; + + const select = (id: number) => { + if (id === selectedId) return; + selectedRef.current = id; + setSelectedId(id); + setDetailNonce(n => n + 1); + setCommentsNonce(n => n + 1); + setAssignError(null); + setPostError(null); + }; + + const doPost = () => { + if (selectedId === null) return; + const text = commentText; + if (text.trim() === "") return; + const id = selectedId; + const who = user; + const token = ++tokenRef.current; + latestPostRef.current = token; + setPostError(null); + setCommentText(""); + setLocalComments(prev => ({ ...prev, [id]: [...(prev[id] ?? []), { token, author: who, text, confirmedAt: null }] })); + addComment(id, who, text).then( + res => { + const at = ++confirmSeqRef.current; + setLocalComments(prev => ({ + ...prev, + [id]: (prev[id] ?? []).map(c => (c.token === token ? { ...c, author: res.author, text: res.text, confirmedAt: at } : c)), + })); + }, + (e: unknown) => { + setLocalComments(prev => ({ ...prev, [id]: (prev[id] ?? []).filter(c => c.token !== token) })); + if (latestPostRef.current === token && selectedRef.current === id) { + setPostError({ id, message: errMessage(e) }); + } + }, + ); + }; + + const retry = () => { + setDetailNonce(n => n + 1); + }; + + const doAssign = () => { + if (selectedId === null) return; + const id = selectedId; + const who = user; + const token = ++tokenRef.current; + latestAssignRef.current = token; + setAssignError(null); + setAssignments(prev => ({ ...prev, [id]: [...(prev[id] ?? []), { token, assignee: who, confirmed: false }] })); + assign(id, who).then( + res => { + setAssignments(prev => { + const entries = prev[id] ?? []; + // A confirmed assignment supersedes older ones. + const idx = entries.findIndex(p => p.token === token); + if (idx < 0) return prev; + const next = entries.slice(idx).map(p => (p.token === token ? { ...p, assignee: res.assignee, confirmed: true } : p)); + return { ...prev, [id]: next }; + }); + }, + (e: unknown) => { + setAssignments(prev => { + const entries = (prev[id] ?? []).filter(p => p.token !== token); + return { ...prev, [id]: entries }; + }); + if (latestAssignRef.current === token && selectedRef.current === id) { + setAssignError({ id, token, message: errMessage(e) }); + } + }, + ); + }; + + const shownAssignee = (issue: Issue): string | null => { + const entries = assignments[issue.id]; + if (entries && entries.length) return entries[entries.length - 1].assignee; + return issue.assignee; + }; + + const listCurrent = list !== null && list.filter === filter && list.nonce === listNonce ? list : null; + + let listBody; + if (!listCurrent) listBody =

    loading {filter} issues

    ; + else if (!listCurrent.ok) listBody =

    list error: {listCurrent.message}

    ; + else + listBody = ( +
      + {listCurrent.issues.map(issue => ( +
    • select(issue.id)} + style={{ cursor: "pointer", fontWeight: issue.id === selectedId ? "bold" : undefined }} + > + {`#${issue.id} ${issue.title} (${shownAssignee(issue) ?? "unassigned"})`} +
    • + ))} +
    + ); + + let detailBody; + if (selectedId === null) detailBody =

    select an issue

    ; + else { + const current = detail !== null && detail.id === selectedId && detail.nonce === detailNonce ? detail : null; + let content; + if (!current) content =

    loading #{selectedId}

    ; + else if (!current.ok) content =

    detail error: {current.message}

    ; + else if (current.issue === null) content =

    detail error: issue #{selectedId} not found

    ; + else + content = ( +
    +

    {`#${current.issue.id} ${current.issue.title}`}

    +

    {current.issue.body}

    +
    + ); + const commentsCurrent = + comments !== null && comments.id === selectedId && comments.nonce === commentsNonce ? comments : null; + const since = commentsCurrent ? commentsCurrent.since : -1; + const locals = (localComments[selectedId] ?? []).filter(c => c.confirmedAt === null || c.confirmedAt > since); + const localRows = locals.map(c => ( +
  • + {c.confirmedAt === null ? `${c.author}: ${c.text} (sending…)` : `${c.author}: ${c.text}`} +
  • + )); + let commentsBody; + if (!commentsCurrent) + commentsBody = ( + <> +

    loading comments

    + {localRows.length > 0 &&
      {localRows}
    } + + ); + else if (!commentsCurrent.ok) + commentsBody = ( + <> +

    comments error: {commentsCurrent.message}

    + {localRows.length > 0 &&
      {localRows}
    } + + ); + else + commentsBody = ( +
      + {commentsCurrent.comments.map((c, i) => ( +
    • {`${c.author}: ${c.text}`}
    • + ))} + {localRows} +
    + ); + detailBody = ( + <> + {content} + + + {assignError && assignError.id === selectedId &&

    {assignError.message}

    } +
    {commentsBody}
    + setCommentText(e.target.value)} /> + + {postError && postError.id === selectedId &&

    post error: {postError.message}

    } + + ); + } + + return ( +
    + +
    + + +
    +
    +
    {listBody}
    +
    {detailBody}
    +
    +
    + ); +} diff --git a/eval/results/change-2026-10-28/runs/big/comments-react-3/runlog.jsonl b/eval/results/change-2026-10-28/runs/big/comments-react-3/runlog.jsonl new file mode 100644 index 0000000..b02e7f8 --- /dev/null +++ b/eval/results/change-2026-10-28/runs/big/comments-react-3/runlog.jsonl @@ -0,0 +1,2 @@ +{"at":"2026-10-28T05:28:10.712Z","cmd":"check","ok":true,"out":"ok"} +{"at":"2026-10-28T05:28:22.693Z","cmd":"test","ok":true,"build":true,"passed":6,"total":6} diff --git a/eval/results/change-2026-10-28/runs/big/comments-rx-1/main.rx b/eval/results/change-2026-10-28/runs/big/comments-rx-1/main.rx new file mode 100644 index 0000000..54c6bbb --- /dev/null +++ b/eval/results/change-2026-10-28/runs/big/comments-rx-1/main.rx @@ -0,0 +1,114 @@ +import pure { fetchIssues, fetchIssue, fetchComments } from "./api" +import { assign, addComment } from "./api" +import pure { indexOf, filter as keep } from "./prelude" + +export error NotFound(id: number) => `issue #${id} not found` + +fn found(it, id: number) => it == null ? throw NotFound(id) : it + +export default component App() { + source user: string := "guest" + source filter: string := "open" + source selected: number | null := null + source issues key id = await fetchIssues(filter) + source assignError: string = "" + source comments = selected == null ? [] : await fetchComments(selected) + source sending: { id: number; author: string; text: string }[] = [] + source draft: string := "" + source postError: string = "" + + action postComment(id: number, who: string, text: string) { + optimistic postError := "" + optimistic sending := [...sending, { id: id, author: who, text: text }] + try { + await addComment(id, who, text) + refresh comments + } catch (e) { + postError := e.message + } + } + + action assignTo(id: number, who: string) { + optimistic assignError := "" + try { + let i = indexOf(issues, t => t.id == id) + optimistic issues[i].assignee := who + await assign(id, who) + refresh issues + } catch (e) { + assignError := e.message + } + } + +
    +
    + +
    +
    + + +
    +
    + {loading on(filter) fallback {

    loading {latest filter} issues

    } { + {errored fallback(e) {

    list error: {e.message}

    } { +
      + {for it in issues { +
    • { + selected := it.id + onFlush(() => { + assignError := "" + postError := "" + }) + }}>#{it.id} {it.title} ({it.assignee ?? "unassigned"})
    • + }} +
    + }} + }} +
    +
    + {if selected != null { + detail = found(await fetchIssue(selected), selected) + {loading on(selected) fallback {

    loading #{latest selected}

    } { + {errored fallback(e) {

    detail error: {e.message}

    } { +

    #{detail.id} {detail.title}

    +

    {detail.body}

    + }} + + }} + + {if assignError != "" { +

    {assignError}

    + }} +
    + {loading on(selected) fallback {

    loading comments

    } { + {errored fallback(e) {

    comments error: {e.message}

    } { +
      + {for c in comments { +
    • {c.author}: {c.text}
    • + }} + {for s in keep(sending, s => s.id == selected) { +
    • {s.author}: {s.text} (sending…)
    • + }} +
    + }} + }} +
    + { draft := e.target.value }} /> + + {if postError != "" { +

    post error: {postError}

    + }} + } else { +

    select an issue

    + }} +
    +
    +} diff --git a/eval/results/change-2026-10-28/runs/big/comments-rx-1/runlog.jsonl b/eval/results/change-2026-10-28/runs/big/comments-rx-1/runlog.jsonl new file mode 100644 index 0000000..7799497 --- /dev/null +++ b/eval/results/change-2026-10-28/runs/big/comments-rx-1/runlog.jsonl @@ -0,0 +1,4 @@ +{"at":"2026-10-28T05:28:08.418Z","cmd":"check","ok":false,"out":"/home/claude/runs/change-big/comments-rx-1/src/main.rx:11: error SHADOWED_IMPORT [MO-1d] filter shadows the imported filter: rename one (`import { filter as … }`)"} +{"at":"2026-10-28T05:28:13.593Z","cmd":"check","ok":true,"out":"src/main.rx: ok"} +{"at":"2026-10-28T05:28:23.679Z","cmd":"test","ok":true,"build":true,"passed":6,"total":6} +{"at":"2026-10-28T05:28:43.420Z","cmd":"test","ok":true,"build":true,"passed":7,"total":7} diff --git a/eval/results/change-2026-10-28/runs/big/comments-rx-2/main.rx b/eval/results/change-2026-10-28/runs/big/comments-rx-2/main.rx new file mode 100644 index 0000000..727edae --- /dev/null +++ b/eval/results/change-2026-10-28/runs/big/comments-rx-2/main.rx @@ -0,0 +1,113 @@ +import pure { fetchIssues, fetchIssue, fetchComments } from "./api" +import { assign, addComment } from "./api" +import pure { indexOf, map } from "./prelude" + +export error NotFound(id: number) => `issue #${id} not found` +fn found(it, id: number) => it == null ? throw NotFound(id) : it + +type AssignError = { id: number; message: string } +type PostError = { id: number; message: string } +type Row = { author: string; text: string; sending: boolean } + +fn rows(cs) => map(cs, c => ({ author: c.author, text: c.text, sending: false })) + +export default component App() { + source user: string := "guest" + source filter: string := "open" + source selectedId: number | null := null + source assignError: AssignError | null := null + source issues key id = await fetchIssues(filter) + + source draft: string := "" + source postError: PostError | null := null + source comments: Row[] = selectedId == null ? [] : rows(await fetchComments(selectedId)) + + detail = selectedId == null ? null : found(await fetchIssue(selectedId), selectedId) + + action assignTo(id: number, who: string) { + try { + let i = indexOf(issues, t => t.id == id) + optimistic issues[i].assignee := who + await assign(id, who) + refresh issues + } catch (e) { + assignError := { id: id, message: e.message } + } + } + + action postComment(id: number, who: string, text: string) { + try { + optimistic comments := [...comments, { author: who, text: text, sending: true }] + await addComment(id, who, text) + refresh comments + } catch (e) { + postError := { id: id, message: e.message } + } + } + +
    + +
    + + +
    +
      + {loading on(filter) fallback {
    • loading {latest filter} issues
    • } { + {errored fallback(e) {
    • list error: {e.message}
    • } { + {for it in issues { +
    • { + onFlush(() => { assignError := null; postError := null }) + selectedId := it.id + }}>#{it.id} {it.title} ({it.assignee ?? "unassigned"})
    • + }} + }} + }} +
    +
    + {if selectedId == null { +

    select an issue

    + } else { + {loading on(selectedId) fallback {

    loading #{latest selectedId}

    } { + {errored fallback(e) {

    detail error: {e.message}

    } { + {if detail { +

    #{detail.id} {detail.title}

    +

    {detail.body}

    + }} + }} + }} + + + {if assignError != null && assignError.id == selectedId { +

    {assignError.message}

    + }} +
    + {loading on(selectedId) fallback {

    loading comments

    } { + {errored fallback(e) {

    comments error: {e.message}

    } { + {for c in comments { +

    {c.author}: {c.text}{c.sending ? " (sending…)" : ""}

    + }} + }} + }} +
    + { draft := ev.target.value }} /> + + {if postError != null && postError.id == selectedId { +

    post error: {postError.message}

    + }} + }} +
    +
    +} diff --git a/eval/results/change-2026-10-28/runs/big/comments-rx-2/runlog.jsonl b/eval/results/change-2026-10-28/runs/big/comments-rx-2/runlog.jsonl new file mode 100644 index 0000000..2efab3e --- /dev/null +++ b/eval/results/change-2026-10-28/runs/big/comments-rx-2/runlog.jsonl @@ -0,0 +1,3 @@ +{"at":"2026-10-28T05:27:44.904Z","cmd":"check","ok":false,"out":"/home/claude/runs/change-big/comments-rx-2/src/main.rx:38: error UNCAUGHT_ACTION_FAILURE [R-f-6] action postComment can fail with Error and does not catch it: a handler cannot (it does not await the action) — `try { … } catch (e) { … }` in the body"} +{"at":"2026-10-28T05:27:49.950Z","cmd":"check","ok":true,"out":"src/main.rx: ok"} +{"at":"2026-10-28T05:28:10.076Z","cmd":"test","ok":true,"build":true,"passed":6,"total":6} diff --git a/eval/results/change-2026-10-28/runs/big/comments-rx-3/main.rx b/eval/results/change-2026-10-28/runs/big/comments-rx-3/main.rx new file mode 100644 index 0000000..e3f14cd --- /dev/null +++ b/eval/results/change-2026-10-28/runs/big/comments-rx-3/main.rx @@ -0,0 +1,117 @@ +import pure { fetchIssues, fetchIssue, fetchComments } from "./api" +import { assign, addComment } from "./api" +import pure { indexOf, filter as keep } from "./prelude" + +export error NotFound(id: number) => `issue #${id} not found` + +fn found(it, id) => it == null ? throw NotFound(id) : it + +export default component App() { + source user: string := "guest" + source filter: string := "open" + source selected: number | null := null + source issues key id = await fetchIssues(filter) + source assignError: { id: number; message: string } | null := null + + detail = selected == null ? null : found(await fetchIssue(selected), selected) + source draft: string := "" + source outbox: { id: number; author: string; text: string }[] = [] + source postError: { id: number; message: string } | null := null + + comments = selected == null ? [] : await fetchComments(selected) + sending = keep(outbox, o => o.id == selected) + shownPostError = postError != null && postError.id == selected ? postError.message : "" + shownError = assignError != null && assignError.id == selected ? assignError.message : "" + + action assignTo(id: number, who: string) { + try { + let i = indexOf(issues, t => t.id == id) + if (i >= 0) { + optimistic issues[i].assignee := who + } + await assign(id, who) + refresh issues + } catch (e) { + assignError := { id: id, message: e.message } + } + } + + action postComment(id: number, who: string, text: string) { + try { + optimistic outbox := [...outbox, { id: id, author: who, text: text }] + await addComment(id, who, text) + refresh comments + } catch (e) { + postError := { id: id, message: e.message } + } + } + +
    + +
    + + +
    +
    +
      + {loading on(filter) fallback {
    • loading {filter} issues
    • } { + {errored fallback(e) {
    • list error: {e.message}
    • } { + {for it in issues { +
    • { + selected := it.id + onFlush(() => { + assignError := null + postError := null + }) + }}>#{it.id} {it.title} ({it.assignee ?? "unassigned"})
    • + }} + }} + }} +
    +
    + {if selected == null { +

    select an issue

    + } else { + {loading on(selected) fallback {

    loading #{selected}

    } { + {errored fallback(e) {

    detail error: {e.message}

    } { +

    #{detail.id} {detail.title}

    +

    {detail.body}

    + }} + }} + + + {if shownError != "" {

    {shownError}

    }} +
    + {loading on(selected) fallback {

    loading comments

    } { + {errored fallback(e) {

    comments error: {e.message}

    } { +
      + {for c in comments { +
    • {c.author}: {c.text}
    • + }} + {for o in sending { +
    • {o.author}: {o.text} (sending…)
    • + }} +
    + }} + }} +
    + { draft := ev.target.value }} /> + + {if shownPostError != "" {

    post error: {shownPostError}

    }} + }} +
    +
    +
    +} diff --git a/eval/results/change-2026-10-28/runs/big/comments-rx-3/runlog.jsonl b/eval/results/change-2026-10-28/runs/big/comments-rx-3/runlog.jsonl new file mode 100644 index 0000000..c60ad61 --- /dev/null +++ b/eval/results/change-2026-10-28/runs/big/comments-rx-3/runlog.jsonl @@ -0,0 +1,3 @@ +{"at":"2026-10-28T05:27:53.527Z","cmd":"check","ok":false,"out":"/home/claude/runs/change-big/comments-rx-3/src/main.rx:11: error SHADOWED_IMPORT [MO-1d] filter shadows the imported filter: rename one (`import { filter as … }`)"} +{"at":"2026-10-28T05:27:58.136Z","cmd":"check","ok":true,"out":"src/main.rx: ok"} +{"at":"2026-10-28T05:28:19.298Z","cmd":"test","ok":true,"build":true,"passed":6,"total":6} diff --git a/eval/results/change-2026-10-28/runs/big/rename-react-1/App.tsx b/eval/results/change-2026-10-28/runs/big/rename-react-1/App.tsx new file mode 100644 index 0000000..3f95fbb --- /dev/null +++ b/eval/results/change-2026-10-28/runs/big/rename-react-1/App.tsx @@ -0,0 +1,240 @@ +import { useRef, useState } from "react"; +import { QueryClient, QueryClientProvider, useQuery, useQueryClient } from "@tanstack/react-query"; +import { assign, fetchIssue, fetchIssues, renameIssue } from "./api.js"; +import type { Issue, IssueDetail } from "./api.js"; + +type Filter = "open" | "closed"; +type Pending = { seq: number; user: string }; +type AssignError = { issueId: number; message: string }; +type PendingTitle = { seq: number; title: string }; + +const errorMessage = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +function Tracker() { + const queryClient = useQueryClient(); + const [user, setUser] = useState("guest"); + const [filter, setFilter] = useState("open"); + const [selected, setSelected] = useState(null); + // optimistic assignments still in the air, per issue, oldest first + const [pending, setPending] = useState>({}); + const [assignError, setAssignError] = useState(null); + const seqRef = useRef(0); + const confirmedSeqRef = useRef>({}); + // optimistic renames still in the air, per issue, oldest first + const [pendingTitles, setPendingTitles] = useState>({}); + const [renameError, setRenameError] = useState(null); + const [titleInput, setTitleInput] = useState(""); + const renameSeqRef = useRef(0); + const confirmedRenameSeqRef = useRef>({}); + + const list = useQuery({ + queryKey: ["issues", filter], + queryFn: () => fetchIssues(filter), + }); + + const detail = useQuery({ + queryKey: ["issue", selected], + queryFn: async () => { + const id = selected as number; + const d = await fetchIssue(id); + if (d === null) throw new Error(`issue #${id} not found`); + return d; + }, + enabled: selected !== null, + }); + + const assigneeOf = (issue: Issue) => { + const p = pending[issue.id]; + if (p && p.length > 0) return p[p.length - 1].user; + return issue.assignee; + }; + + const titleOf = (id: number, title: string) => { + const p = pendingTitles[id]; + if (p && p.length > 0) return p[p.length - 1].title; + return title; + }; + + const select = (id: number) => { + setSelected(id); + setAssignError(null); + setRenameError(null); + setTitleInput(""); + }; + + const doRename = async (id: number, title: string) => { + const seq = ++renameSeqRef.current; + setRenameError(null); + setPendingTitles(prev => ({ ...prev, [id]: [...(prev[id] ?? []), { seq, title }] })); + const done = () => + setPendingTitles(prev => { + const rest = (prev[id] ?? []).filter(p => p.seq !== seq); + const next = { ...prev }; + if (rest.length) next[id] = rest; + else delete next[id]; + return next; + }); + try { + const res = await renameIssue(id, title); + if ((confirmedRenameSeqRef.current[id] ?? 0) < seq) { + confirmedRenameSeqRef.current[id] = seq; + queryClient.setQueriesData({ queryKey: ["issues"] }, old => + old?.map(i => (i.id === id ? { ...i, title: res.title } : i)), + ); + queryClient.setQueryData(["issue", id], old => + old ? { ...old, title: res.title } : old, + ); + } + done(); + } catch (e) { + done(); + // only report the failure if no later rename has started since + if (renameSeqRef.current === seq) setRenameError({ issueId: id, message: errorMessage(e) }); + } + }; + + const doAssign = async (id: number, who: string) => { + const seq = ++seqRef.current; + setAssignError(null); + setPending(prev => ({ ...prev, [id]: [...(prev[id] ?? []), { seq, user: who }] })); + const done = () => + setPending(prev => { + const rest = (prev[id] ?? []).filter(p => p.seq !== seq); + const next = { ...prev }; + if (rest.length) next[id] = rest; + else delete next[id]; + return next; + }); + try { + const res = await assign(id, who); + if ((confirmedSeqRef.current[id] ?? 0) < seq) { + confirmedSeqRef.current[id] = seq; + queryClient.setQueriesData({ queryKey: ["issues"] }, old => + old?.map(i => (i.id === id ? { ...i, assignee: res.assignee } : i)), + ); + } + done(); + } catch (e) { + done(); + // only report the failure if no later assignment has started since + if (seqRef.current === seq) setAssignError({ issueId: id, message: errorMessage(e) }); + } + }; + + let listContent; + if (list.isPending) listContent =

    loading {filter} issues

    ; + else if (list.isError) listContent =

    list error: {errorMessage(list.error)}

    ; + else + listContent = ( +
      + {list.data.map(issue => ( +
    • + +
    • + ))} +
    + ); + + let detailContent; + if (selected === null) detailContent =

    select an issue

    ; + else { + let body; + if (detail.isFetching || detail.isPending) body =

    loading #{selected}

    ; + else if (detail.isError) body =

    detail error: {errorMessage(detail.error)}

    ; + else + body = ( + <> +

    + #{detail.data.id} {titleOf(detail.data.id, detail.data.title)} +

    +

    {detail.data.body}

    + + ); + const id = selected; + detailContent = ( + <> + {body} + + + {assignError && assignError.issueId === selected && ( +

    {assignError.message}

    + )} +
    + setTitleInput(e.target.value)} + /> + +
    + {renameError && renameError.issueId === selected && ( +

    rename error: {renameError.message}

    + )} + + ); + } + + return ( +
    +
    + +
    + + +
    +
    {listContent}
    +
    +
    {detailContent}
    +
    + ); +} + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, refetchOnWindowFocus: false, refetchOnReconnect: false }, + }, +}); + +export default function App() { + return ( + + + + ); +} diff --git a/eval/results/change-2026-10-28/runs/big/rename-react-1/runlog.jsonl b/eval/results/change-2026-10-28/runs/big/rename-react-1/runlog.jsonl new file mode 100644 index 0000000..f8ee40e --- /dev/null +++ b/eval/results/change-2026-10-28/runs/big/rename-react-1/runlog.jsonl @@ -0,0 +1,2 @@ +{"at":"2026-10-28T05:27:47.848Z","cmd":"check","ok":true,"out":"ok"} +{"at":"2026-10-28T05:28:08.896Z","cmd":"test","ok":true,"build":true,"passed":6,"total":6} diff --git a/eval/results/change-2026-10-28/runs/big/rename-react-2/App.tsx b/eval/results/change-2026-10-28/runs/big/rename-react-2/App.tsx new file mode 100644 index 0000000..11964bc --- /dev/null +++ b/eval/results/change-2026-10-28/runs/big/rename-react-2/App.tsx @@ -0,0 +1,211 @@ +import { useEffect, useState } from "react"; +import { assign, fetchIssue, fetchIssues, renameIssue } from "./api.js"; +import type { Issue, IssueDetail } from "./api.js"; + +type Filter = "open" | "closed"; +type ListReq = { filter: Filter }; +type ListRes = + | { req: ListReq; startedAt: number; ok: true; data: Issue[] } + | { req: ListReq; startedAt: number; ok: false; error: string }; +type SelReq = { id: number }; +type DetRes = + | { req: SelReq; startedAt: number; ok: true; data: IssueDetail | null } + | { req: SelReq; startedAt: number; ok: false; error: string }; +type Pending = { seq: number; id: number; user: string }; +type Confirmed = Record; +type AssignState = { seq: number; id: number; error: string | null } | null; +type PendingRename = { seq: number; id: number; title: string }; +type ConfirmedTitles = Record; +type RenameState = { seq: number; id: number; error: string | null } | null; + +// Logical clock used to tell whether a list response predates a confirmed assignment. +let clock = 0; +let assignSeq = 0; +let renameSeq = 0; + +const message = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +export default function App() { + const [user, setUser] = useState("guest"); + const [listReq, setListReq] = useState({ filter: "open" }); + const [listRes, setListRes] = useState(null); + const [sel, setSel] = useState(null); + const [detRes, setDetRes] = useState(null); + const [pending, setPending] = useState([]); + const [confirmed, setConfirmed] = useState({}); + const [assignState, setAssignState] = useState(null); + const [titleDraft, setTitleDraft] = useState(""); + const [pendingRenames, setPendingRenames] = useState([]); + const [confirmedTitles, setConfirmedTitles] = useState({}); + const [renameState, setRenameState] = useState(null); + + useEffect(() => { + let cancelled = false; + const startedAt = ++clock; + fetchIssues(listReq.filter).then( + data => { if (!cancelled) setListRes({ req: listReq, startedAt, ok: true, data }); }, + (e: unknown) => { if (!cancelled) setListRes({ req: listReq, startedAt, ok: false, error: message(e) }); }, + ); + return () => { cancelled = true; }; + }, [listReq]); + + useEffect(() => { + if (!sel) return; + let cancelled = false; + const startedAt = ++clock; + fetchIssue(sel.id).then( + data => { if (!cancelled) setDetRes({ req: sel, startedAt, ok: true, data }); }, + (e: unknown) => { if (!cancelled) setDetRes({ req: sel, startedAt, ok: false, error: message(e) }); }, + ); + return () => { cancelled = true; }; + }, [sel]); + + const chooseFilter = (filter: Filter) => { + if (filter !== listReq.filter) setListReq({ filter }); + }; + + const select = (id: number) => { + if (sel && sel.id === id) return; + setSel({ id }); + setAssignState(null); + setRenameState(null); + setTitleDraft(""); + }; + + const retry = () => { + if (sel) setSel({ id: sel.id }); + }; + + const doAssign = () => { + if (!sel) return; + const id = sel.id; + const seq = ++assignSeq; + const who = user; + setPending(p => [...p, { seq, id, user: who }]); + setAssignState({ seq, id, error: null }); + assign(id, who).then( + res => { + const at = ++clock; + setConfirmed(c => ({ ...c, [id]: { assignee: res.assignee, at } })); + setPending(p => p.filter(x => x.seq !== seq)); + }, + (e: unknown) => { + setPending(p => p.filter(x => x.seq !== seq)); + setAssignState(s => (s && s.seq === seq ? { ...s, error: message(e) } : s)); + }, + ); + }; + + const doRename = () => { + if (!sel) return; + const title = titleDraft; + if (title.trim() === "") return; + const id = sel.id; + const seq = ++renameSeq; + setPendingRenames(p => [...p, { seq, id, title }]); + setRenameState({ seq, id, error: null }); + renameIssue(id, title).then( + res => { + const at = ++clock; + setConfirmedTitles(c => ({ ...c, [id]: { title: res.title, at } })); + setPendingRenames(p => p.filter(x => x.seq !== seq)); + }, + (e: unknown) => { + setPendingRenames(p => p.filter(x => x.seq !== seq)); + setRenameState(s => (s && s.seq === seq ? { ...s, error: message(e) } : s)); + }, + ); + }; + + const titleOf = (id: number, title: string, startedAt: number): string => { + let latest: PendingRename | null = null; + for (const p of pendingRenames) if (p.id === id && (!latest || p.seq > latest.seq)) latest = p; + if (latest) return latest.title; + const c = confirmedTitles[id]; + if (c && c.at > startedAt) return c.title; + return title; + }; + + const assigneeOf = (row: Issue, startedAt: number): string | null => { + let latest: Pending | null = null; + for (const p of pending) if (p.id === row.id && (!latest || p.seq > latest.seq)) latest = p; + if (latest) return latest.user; + const c = confirmed[row.id]; + if (c && c.at > startedAt) return c.assignee; + return row.assignee; + }; + + const filter = listReq.filter; + let list: React.ReactNode; + if (!listRes || listRes.req !== listReq) list =

    loading {filter} issues

    ; + else if (!listRes.ok) list =

    list error: {listRes.error}

    ; + else + list = ( +
      + {listRes.data.map(row => { + const a = assigneeOf(row, listRes.startedAt); + return ( +
    • select(row.id)} + style={{ cursor: "pointer", fontWeight: sel?.id === row.id ? "bold" : undefined }} + > + {`#${row.id} ${titleOf(row.id, row.title, listRes.startedAt)} (${a ?? "unassigned"})`} +
    • + ); + })} +
    + ); + + let detail: React.ReactNode; + if (!sel) detail =

    select an issue

    ; + else { + let body: React.ReactNode; + if (!detRes || detRes.req !== sel) body =

    loading #{sel.id}

    ; + else if (!detRes.ok) body =

    detail error: {detRes.error}

    ; + else if (!detRes.data) body =

    detail error: issue #{sel.id} not found

    ; + else + body = ( + <> +

    {`#${detRes.data.id} ${titleOf(detRes.data.id, detRes.data.title, detRes.startedAt)}`}

    +

    {detRes.data.body}

    + + ); + const assignError = assignState && assignState.id === sel.id ? assignState.error : null; + const renameError = renameState && renameState.id === sel.id ? renameState.error : null; + detail = ( + <> + {body} + {" "} + + {assignError !== null &&

    {assignError}

    } +
    + setTitleDraft(e.target.value)} + />{" "} + +
    + {renameError !== null &&

    {`rename error: ${renameError}`}

    } + + ); + } + + return ( +
    + +
    + {" "} + +
    +
    +
    {list}
    +
    {detail}
    +
    +
    + ); +} diff --git a/eval/results/change-2026-10-28/runs/big/rename-react-2/runlog.jsonl b/eval/results/change-2026-10-28/runs/big/rename-react-2/runlog.jsonl new file mode 100644 index 0000000..8e12ffc --- /dev/null +++ b/eval/results/change-2026-10-28/runs/big/rename-react-2/runlog.jsonl @@ -0,0 +1,2 @@ +{"at":"2026-10-28T05:27:52.770Z","cmd":"check","ok":true,"out":"ok"} +{"at":"2026-10-28T05:28:13.816Z","cmd":"test","ok":true,"build":true,"passed":6,"total":6} diff --git a/eval/results/change-2026-10-28/runs/big/rename-react-3/App.tsx b/eval/results/change-2026-10-28/runs/big/rename-react-3/App.tsx new file mode 100644 index 0000000..37cb28e --- /dev/null +++ b/eval/results/change-2026-10-28/runs/big/rename-react-3/App.tsx @@ -0,0 +1,224 @@ +import { useEffect, useRef, useState } from "react"; +import { assign, fetchIssue, fetchIssues, renameIssue } from "./api.js"; +import type { Issue, IssueDetail } from "./api.js"; + +type Filter = "open" | "closed"; + +type ListResult = + | { filter: Filter; nonce: number; ok: true; issues: Issue[] } + | { filter: Filter; nonce: number; ok: false; message: string }; + +type DetailResult = + | { id: number; nonce: number; ok: true; issue: IssueDetail | null } + | { id: number; nonce: number; ok: false; message: string }; + +type Pending = { token: number; assignee: string; confirmed: boolean }; +type PendingTitle = { token: number; title: string; confirmed: boolean }; + +const errMessage = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +export default function App() { + const [user, setUser] = useState("guest"); + const [filter, setFilter] = useState("open"); + const [listNonce, setListNonce] = useState(0); + const [list, setList] = useState(null); + + const [selectedId, setSelectedId] = useState(null); + const [detailNonce, setDetailNonce] = useState(0); + const [detail, setDetail] = useState(null); + + // Per-issue assignments, oldest first; the latest still standing wins. + const [assignments, setAssignments] = useState>({}); + const [assignError, setAssignError] = useState<{ id: number; token: number; message: string } | null>(null); + const tokenRef = useRef(0); + const latestAssignRef = useRef(0); + const selectedRef = useRef(null); + + // Per-issue renames, oldest first; the latest still standing wins. + const [titleInput, setTitleInput] = useState(""); + const [renames, setRenames] = useState>({}); + const [renameError, setRenameError] = useState<{ id: number; token: number; message: string } | null>(null); + const renameTokenRef = useRef(0); + const latestRenameRef = useRef(0); + + useEffect(() => { + let live = true; + fetchIssues(filter).then( + issues => { if (live) setList({ filter, nonce: listNonce, ok: true, issues }); }, + (e: unknown) => { if (live) setList({ filter, nonce: listNonce, ok: false, message: errMessage(e) }); }, + ); + return () => { live = false; }; + }, [filter, listNonce]); + + useEffect(() => { + if (selectedId === null) return; + let live = true; + const id = selectedId; + fetchIssue(id).then( + issue => { if (live) setDetail({ id, nonce: detailNonce, ok: true, issue }); }, + (e: unknown) => { if (live) setDetail({ id, nonce: detailNonce, ok: false, message: errMessage(e) }); }, + ); + return () => { live = false; }; + }, [selectedId, detailNonce]); + + const chooseFilter = (f: Filter) => { + if (f === filter) return; + setFilter(f); + setListNonce(n => n + 1); + }; + + const select = (id: number) => { + if (id === selectedId) return; + selectedRef.current = id; + setSelectedId(id); + setDetailNonce(n => n + 1); + setAssignError(null); + setRenameError(null); + setTitleInput(""); + }; + + const retry = () => { + setDetailNonce(n => n + 1); + }; + + const doAssign = () => { + if (selectedId === null) return; + const id = selectedId; + const who = user; + const token = ++tokenRef.current; + latestAssignRef.current = token; + setAssignError(null); + setAssignments(prev => ({ ...prev, [id]: [...(prev[id] ?? []), { token, assignee: who, confirmed: false }] })); + assign(id, who).then( + res => { + setAssignments(prev => { + const entries = prev[id] ?? []; + // A confirmed assignment supersedes older ones. + const idx = entries.findIndex(p => p.token === token); + if (idx < 0) return prev; + const next = entries.slice(idx).map(p => (p.token === token ? { ...p, assignee: res.assignee, confirmed: true } : p)); + return { ...prev, [id]: next }; + }); + }, + (e: unknown) => { + setAssignments(prev => { + const entries = (prev[id] ?? []).filter(p => p.token !== token); + return { ...prev, [id]: entries }; + }); + if (latestAssignRef.current === token && selectedRef.current === id) { + setAssignError({ id, token, message: errMessage(e) }); + } + }, + ); + }; + + const doRename = () => { + if (selectedId === null) return; + const title = titleInput; + if (title === "") return; + const id = selectedId; + const token = ++renameTokenRef.current; + latestRenameRef.current = token; + setRenameError(null); + setRenames(prev => ({ ...prev, [id]: [...(prev[id] ?? []), { token, title, confirmed: false }] })); + renameIssue(id, title).then( + res => { + setRenames(prev => { + const entries = prev[id] ?? []; + const idx = entries.findIndex(p => p.token === token); + if (idx < 0) return prev; + const next = entries.slice(idx).map(p => (p.token === token ? { ...p, title: res.title, confirmed: true } : p)); + return { ...prev, [id]: next }; + }); + }, + (e: unknown) => { + setRenames(prev => { + const entries = (prev[id] ?? []).filter(p => p.token !== token); + return { ...prev, [id]: entries }; + }); + if (latestRenameRef.current === token && selectedRef.current === id) { + setRenameError({ id, token, message: errMessage(e) }); + } + }, + ); + }; + + const shownTitle = (id: number, serverTitle: string): string => { + const entries = renames[id]; + if (entries && entries.length) return entries[entries.length - 1].title; + return serverTitle; + }; + + const shownAssignee = (issue: Issue): string | null => { + const entries = assignments[issue.id]; + if (entries && entries.length) return entries[entries.length - 1].assignee; + return issue.assignee; + }; + + const listCurrent = list !== null && list.filter === filter && list.nonce === listNonce ? list : null; + + let listBody; + if (!listCurrent) listBody =

    loading {filter} issues

    ; + else if (!listCurrent.ok) listBody =

    list error: {listCurrent.message}

    ; + else + listBody = ( +
      + {listCurrent.issues.map(issue => ( +
    • select(issue.id)} + style={{ cursor: "pointer", fontWeight: issue.id === selectedId ? "bold" : undefined }} + > + {`#${issue.id} ${shownTitle(issue.id, issue.title)} (${shownAssignee(issue) ?? "unassigned"})`} +
    • + ))} +
    + ); + + let detailBody; + if (selectedId === null) detailBody =

    select an issue

    ; + else { + const current = detail !== null && detail.id === selectedId && detail.nonce === detailNonce ? detail : null; + let content; + if (!current) content =

    loading #{selectedId}

    ; + else if (!current.ok) content =

    detail error: {current.message}

    ; + else if (current.issue === null) content =

    detail error: issue #{selectedId} not found

    ; + else + content = ( +
    +

    {`#${current.issue.id} ${shownTitle(current.issue.id, current.issue.title)}`}

    +

    {current.issue.body}

    +
    + ); + detailBody = ( + <> + {content} + + + {assignError && assignError.id === selectedId &&

    {assignError.message}

    } +
    + setTitleInput(e.target.value)} /> + +
    + {renameError && renameError.id === selectedId &&

    rename error: {renameError.message}

    } + + ); + } + + return ( +
    + +
    + + +
    +
    +
    {listBody}
    +
    {detailBody}
    +
    +
    + ); +} diff --git a/eval/results/change-2026-10-28/runs/big/rename-react-3/runlog.jsonl b/eval/results/change-2026-10-28/runs/big/rename-react-3/runlog.jsonl new file mode 100644 index 0000000..823c33e --- /dev/null +++ b/eval/results/change-2026-10-28/runs/big/rename-react-3/runlog.jsonl @@ -0,0 +1,2 @@ +{"at":"2026-10-28T05:27:49.606Z","cmd":"check","ok":true,"out":"ok"} +{"at":"2026-10-28T05:28:08.972Z","cmd":"test","ok":true,"build":true,"passed":6,"total":6} diff --git a/eval/results/change-2026-10-28/runs/big/rename-rx-1/main.rx b/eval/results/change-2026-10-28/runs/big/rename-rx-1/main.rx new file mode 100644 index 0000000..381376c --- /dev/null +++ b/eval/results/change-2026-10-28/runs/big/rename-rx-1/main.rx @@ -0,0 +1,100 @@ +import pure { fetchIssues, fetchIssue } from "./api" +import { assign, renameIssue } from "./api" +import pure { indexOf } from "./prelude" + +export error NotFound(id: number) => `issue #${id} not found` + +fn found(it, id: number) => it == null ? throw NotFound(id) : it + +export default component App() { + source user: string := "guest" + source filter: string := "open" + source selected: number | null := null + source issues key id = await fetchIssues(filter) + source assignError: string = "" + source renameError: string = "" + source headTitle: { id: number; title: string } | null = null + source draft: string := "" + + action assignTo(id: number, who: string) { + optimistic assignError := "" + try { + let i = indexOf(issues, t => t.id == id) + optimistic issues[i].assignee := who + await assign(id, who) + refresh issues + } catch (e) { + assignError := e.message + } + } + + action renameTo(id: number, title: string) { + optimistic renameError := "" + optimistic headTitle := { id: id, title: title } + try { + let i = indexOf(issues, t => t.id == id) + if (i >= 0) { + optimistic issues[i].title := title + } + let answer = await renameIssue(id, title) + headTitle := { id: answer.id, title: answer.title } + refresh issues + } catch (e) { + renameError := e.message + } + } + +
    +
    + +
    +
    + + +
    +
    + {loading on(filter) fallback {

    loading {latest filter} issues

    } { + {errored fallback(e) {

    list error: {e.message}

    } { +
      + {for it in issues { +
    • { + selected := it.id + draft := "" + onFlush(() => { assignError := "" }) + onFlush(() => { renameError := "" }) + }}>#{it.id} {it.title} ({it.assignee ?? "unassigned"})
    • + }} +
    + }} + }} +
    +
    + {if selected != null { + detail = found(await fetchIssue(selected), selected) + {loading on(selected) fallback {

    loading #{latest selected}

    } { + {errored fallback(e) {

    detail error: {e.message}

    } { +

    #{detail.id} {headTitle != null && headTitle.id == detail.id ? headTitle.title : detail.title}

    +

    {detail.body}

    + }} + + }} + + {if assignError != "" { +

    {assignError}

    + }} +
    + { draft := e.target.value }} /> + +
    + {if renameError != "" { +

    rename error: {renameError}

    + }} + } else { +

    select an issue

    + }} +
    +
    +} diff --git a/eval/results/change-2026-10-28/runs/big/rename-rx-1/runlog.jsonl b/eval/results/change-2026-10-28/runs/big/rename-rx-1/runlog.jsonl new file mode 100644 index 0000000..9d358ba --- /dev/null +++ b/eval/results/change-2026-10-28/runs/big/rename-rx-1/runlog.jsonl @@ -0,0 +1,4 @@ +{"at":"2026-10-28T05:27:49.568Z","cmd":"check","ok":true,"out":"src/main.rx: ok"} +{"at":"2026-10-28T05:28:13.392Z","cmd":"test","ok":true,"build":true,"passed":6,"total":6} +{"at":"2026-10-28T05:28:33.395Z","cmd":"test","ok":true,"build":true,"passed":7,"total":7} +{"at":"2026-10-28T05:28:37.283Z","cmd":"check","ok":true,"out":"src/main.rx: ok"} diff --git a/eval/results/change-2026-10-28/runs/big/rename-rx-2/main.rx b/eval/results/change-2026-10-28/runs/big/rename-rx-2/main.rx new file mode 100644 index 0000000..c543e86 --- /dev/null +++ b/eval/results/change-2026-10-28/runs/big/rename-rx-2/main.rx @@ -0,0 +1,103 @@ +import pure { fetchIssues, fetchIssue } from "./api" +import { assign, renameIssue } from "./api" +import pure { indexOf } from "./prelude" + +export error NotFound(id: number) => `issue #${id} not found` +fn found(it, id: number) => it == null ? throw NotFound(id) : it + +type AssignError = { id: number; message: string } +type RenameError = { id: number; message: string } + +export default component App() { + source user: string := "guest" + source filter: string := "open" + source selectedId: number | null := null + source assignError: AssignError | null := null + source renameError: RenameError | null := null + source titleDraft: string := "" + source issues key id = await fetchIssues(filter) + + source detail = selectedId == null ? null : found(await fetchIssue(selectedId), selectedId) + + action assignTo(id: number, who: string) { + try { + let i = indexOf(issues, t => t.id == id) + optimistic issues[i].assignee := who + await assign(id, who) + refresh issues + } catch (e) { + assignError := { id: id, message: e.message } + } + } + + action renameTo(id: number, title: string) { + try { + let i = indexOf(issues, t => t.id == id) + optimistic issues[i].title := title + if (detail != null && detail.id == id) { + optimistic detail.title := title + } + await renameIssue(id, title) + refresh issues + refresh detail + } catch (e) { + renameError := { id: id, message: e.message } + } + } + +
    + +
    + + +
    +
      + {loading on(filter) fallback {
    • loading {latest filter} issues
    • } { + {errored fallback(e) {
    • list error: {e.message}
    • } { + {for it in issues { +
    • { + onFlush(() => { assignError := null }) + onFlush(() => { renameError := null }) + titleDraft := "" + selectedId := it.id + }}>#{it.id} {it.title} ({it.assignee ?? "unassigned"})
    • + }} + }} + }} +
    +
    + {if selectedId == null { +

    select an issue

    + } else { + {loading on(selectedId) fallback {

    loading #{latest selectedId}

    } { + {errored fallback(e) {

    detail error: {e.message}

    } { + {if detail { +

    #{detail.id} {detail.title}

    +

    {detail.body}

    + }} + }} + }} + + + { titleDraft := ev.target.value }} /> + + {if renameError != null && renameError.id == selectedId { +

    rename error: {renameError.message}

    + }} + {if assignError != null && assignError.id == selectedId { +

    {assignError.message}

    + }} + }} +
    +
    +} diff --git a/eval/results/change-2026-10-28/runs/big/rename-rx-2/runlog.jsonl b/eval/results/change-2026-10-28/runs/big/rename-rx-2/runlog.jsonl new file mode 100644 index 0000000..6c4e7d4 --- /dev/null +++ b/eval/results/change-2026-10-28/runs/big/rename-rx-2/runlog.jsonl @@ -0,0 +1,3 @@ +{"at":"2026-10-28T05:27:51.943Z","cmd":"check","ok":true,"out":"src/main.rx: ok"} +{"at":"2026-10-28T05:28:16.768Z","cmd":"test","ok":true,"build":true,"passed":6,"total":6} +{"at":"2026-10-28T05:28:40.286Z","cmd":"test","ok":false,"build":true,"passed":8,"total":9} diff --git a/eval/results/change-2026-10-28/runs/big/rename-rx-3/main.rx b/eval/results/change-2026-10-28/runs/big/rename-rx-3/main.rx new file mode 100644 index 0000000..3a93404 --- /dev/null +++ b/eval/results/change-2026-10-28/runs/big/rename-rx-3/main.rx @@ -0,0 +1,107 @@ +import pure { fetchIssues, fetchIssue } from "./api" +import { assign, renameIssue } from "./api" +import pure { indexOf } from "./prelude" + +export error NotFound(id: number) => `issue #${id} not found` + +fn found(it, id) => it == null ? throw NotFound(id) : it + +export default component App() { + source user: string := "guest" + source filter: string := "open" + source selected: number | null := null + source issues key id = await fetchIssues(filter) + source assignError: { id: number; message: string } | null := null + + source renameError: { id: number; message: string } | null := null + source titleDraft: string := "" + + source detail = selected == null ? null : found(await fetchIssue(selected), selected) + shownRenameError = renameError != null && renameError.id == selected ? renameError.message : "" + shownError = assignError != null && assignError.id == selected ? assignError.message : "" + + action assignTo(id: number, who: string) { + try { + let i = indexOf(issues, t => t.id == id) + if (i >= 0) { + optimistic issues[i].assignee := who + } + await assign(id, who) + refresh issues + } catch (e) { + assignError := { id: id, message: e.message } + } + } + + action renameTo(id: number, title: string) { + try { + let d = detail + if (d != null && d.id == id) { + optimistic detail := { id: d.id, title: title, body: d.body } + } + let i = indexOf(issues, t => t.id == id) + if (i >= 0) { + optimistic issues[i].title := title + } + let answer = await renameIssue(id, title) + if (d != null && d.id == id) { + detail := { id: d.id, title: answer.title, body: d.body } + } + refresh issues + } catch (e) { + renameError := { id: id, message: e.message } + } + } + +
    + +
    + + +
    +
    +
      + {loading on(filter) fallback {
    • loading {filter} issues
    • } { + {errored fallback(e) {
    • list error: {e.message}
    • } { + {for it in issues { +
    • { + selected := it.id + titleDraft := "" + onFlush(() => { assignError := null }) + onFlush(() => { renameError := null }) + }}>#{it.id} {it.title} ({it.assignee ?? "unassigned"})
    • + }} + }} + }} +
    +
    + {if selected == null { +

    select an issue

    + } else { + {loading on(selected) fallback {

    loading #{selected}

    } { + {errored fallback(e) {

    detail error: {e.message}

    } { +

    #{detail.id} {detail.title}

    +

    {detail.body}

    + }} + }} + + + {if shownError != "" {

    {shownError}

    }} +
    + { titleDraft := ev.target.value }} /> + +
    + {if shownRenameError != "" {

    rename error: {shownRenameError}

    }} + }} +
    +
    +
    +} diff --git a/eval/results/change-2026-10-28/runs/big/rename-rx-3/runlog.jsonl b/eval/results/change-2026-10-28/runs/big/rename-rx-3/runlog.jsonl new file mode 100644 index 0000000..f0f0eaa --- /dev/null +++ b/eval/results/change-2026-10-28/runs/big/rename-rx-3/runlog.jsonl @@ -0,0 +1,3 @@ +{"at":"2026-10-28T05:27:47.923Z","cmd":"check","ok":true,"out":"src/main.rx: ok"} +{"at":"2026-10-28T05:28:12.687Z","cmd":"test","ok":true,"build":true,"passed":6,"total":6} +{"at":"2026-10-28T05:28:33.495Z","cmd":"test","ok":true,"build":true,"passed":7,"total":7} diff --git a/eval/results/change-2026-10-28/runs/small/close-react-1/App.tsx b/eval/results/change-2026-10-28/runs/small/close-react-1/App.tsx new file mode 100644 index 0000000..ab1b188 --- /dev/null +++ b/eval/results/change-2026-10-28/runs/small/close-react-1/App.tsx @@ -0,0 +1,213 @@ +import { useRef, useState } from "react"; +import { QueryClient, QueryClientProvider, useQuery, useQueryClient } from "@tanstack/react-query"; +import { assign, closeIssue, fetchIssue, fetchIssues } from "./api.js"; +import type { Issue } from "./api.js"; + +type Filter = "open" | "closed"; +type Pending = { seq: number; user: string }; +type AssignError = { issueId: number; message: string }; +type CloseError = { issueId: number; message: string }; + +const errorMessage = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +function Tracker() { + const queryClient = useQueryClient(); + const [user, setUser] = useState("guest"); + const [filter, setFilter] = useState("open"); + const [selected, setSelected] = useState(null); + // optimistic assignments still in the air, per issue, oldest first + const [pending, setPending] = useState>({}); + const [assignError, setAssignError] = useState(null); + const seqRef = useRef(0); + const confirmedSeqRef = useRef>({}); + // closes in the air, per issue (their rows are hidden at once) + const [closing, setClosing] = useState>({}); + // issues the server has accepted closing + const [closedIds, setClosedIds] = useState>({}); + const [closeError, setCloseError] = useState(null); + const closeSeqRef = useRef(0); + + const list = useQuery({ + queryKey: ["issues", filter], + queryFn: () => fetchIssues(filter), + }); + + const detail = useQuery({ + queryKey: ["issue", selected], + queryFn: async () => { + const id = selected as number; + const d = await fetchIssue(id); + if (d === null) throw new Error(`issue #${id} not found`); + return d; + }, + enabled: selected !== null, + }); + + const assigneeOf = (issue: Issue) => { + const p = pending[issue.id]; + if (p && p.length > 0) return p[p.length - 1].user; + return issue.assignee; + }; + + const select = (id: number) => { + setSelected(id); + setAssignError(null); + setCloseError(null); + closeSeqRef.current++; + }; + + const isHidden = (id: number) => (closing[id] ?? 0) > 0 || closedIds[id] === true; + + const doClose = async (id: number) => { + const seq = ++closeSeqRef.current; + setCloseError(null); + setClosing(prev => ({ ...prev, [id]: (prev[id] ?? 0) + 1 })); + try { + await closeIssue(id); + setClosedIds(prev => ({ ...prev, [id]: true })); + } catch (e) { + // only report the failure if the issue is still the one being looked at + if (closeSeqRef.current === seq) setCloseError({ issueId: id, message: errorMessage(e) }); + } finally { + setClosing(prev => { + const left = (prev[id] ?? 1) - 1; + const next = { ...prev }; + if (left > 0) next[id] = left; + else delete next[id]; + return next; + }); + } + }; + + const doAssign = async (id: number, who: string) => { + const seq = ++seqRef.current; + setAssignError(null); + setPending(prev => ({ ...prev, [id]: [...(prev[id] ?? []), { seq, user: who }] })); + const done = () => + setPending(prev => { + const rest = (prev[id] ?? []).filter(p => p.seq !== seq); + const next = { ...prev }; + if (rest.length) next[id] = rest; + else delete next[id]; + return next; + }); + try { + const res = await assign(id, who); + if ((confirmedSeqRef.current[id] ?? 0) < seq) { + confirmedSeqRef.current[id] = seq; + queryClient.setQueriesData({ queryKey: ["issues"] }, old => + old?.map(i => (i.id === id ? { ...i, assignee: res.assignee } : i)), + ); + } + done(); + } catch (e) { + done(); + // only report the failure if no later assignment has started since + if (seqRef.current === seq) setAssignError({ issueId: id, message: errorMessage(e) }); + } + }; + + let listContent; + if (list.isPending) listContent =

    loading {filter} issues

    ; + else if (list.isError) listContent =

    list error: {errorMessage(list.error)}

    ; + else + listContent = ( +
      + {list.data.filter(issue => !(filter === "open" && isHidden(issue.id))).map(issue => ( +
    • + +
    • + ))} +
    + ); + + let detailContent; + if (selected === null) detailContent =

    select an issue

    ; + else { + let body; + if (detail.isFetching || detail.isPending) body =

    loading #{selected}

    ; + else if (detail.isError) body =

    detail error: {errorMessage(detail.error)}

    ; + else + body = ( + <> +

    + #{detail.data.id} {detail.data.title} +

    +

    {detail.data.body}

    + + ); + const id = selected; + detailContent = ( + <> + {body} + + + + {assignError && assignError.issueId === selected && ( +

    {assignError.message}

    + )} + {closeError && closeError.issueId === selected && ( +

    close error: {closeError.message}

    + )} + + ); + } + + return ( +
    +
    + +
    + + +
    +
    {listContent}
    +
    +
    {detailContent}
    +
    + ); +} + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, refetchOnWindowFocus: false, refetchOnReconnect: false }, + }, +}); + +export default function App() { + return ( + + + + ); +} diff --git a/eval/results/change-2026-10-28/runs/small/close-react-1/runlog.jsonl b/eval/results/change-2026-10-28/runs/small/close-react-1/runlog.jsonl new file mode 100644 index 0000000..c021ed5 --- /dev/null +++ b/eval/results/change-2026-10-28/runs/small/close-react-1/runlog.jsonl @@ -0,0 +1,2 @@ +{"at":"2026-10-28T05:35:03.309Z","cmd":"check","ok":true,"out":"ok"} +{"at":"2026-10-28T05:35:16.961Z","cmd":"test","ok":true,"build":true,"passed":6,"total":6} diff --git a/eval/results/change-2026-10-28/runs/small/close-react-2/App.tsx b/eval/results/change-2026-10-28/runs/small/close-react-2/App.tsx new file mode 100644 index 0000000..4833ad6 --- /dev/null +++ b/eval/results/change-2026-10-28/runs/small/close-react-2/App.tsx @@ -0,0 +1,189 @@ +import { useEffect, useState } from "react"; +import { assign, closeIssue, fetchIssue, fetchIssues } from "./api.js"; +import type { Issue, IssueDetail } from "./api.js"; + +type Filter = "open" | "closed"; +type ListReq = { filter: Filter }; +type ListRes = + | { req: ListReq; startedAt: number; ok: true; data: Issue[] } + | { req: ListReq; startedAt: number; ok: false; error: string }; +type SelReq = { id: number }; +type DetRes = + | { req: SelReq; ok: true; data: IssueDetail | null } + | { req: SelReq; ok: false; error: string }; +type Pending = { seq: number; id: number; user: string }; +type Confirmed = Record; +type AssignState = { seq: number; id: number; error: string | null } | null; +type CloseState = { seq: number; id: number; error: string | null } | null; + +// Logical clock used to tell whether a list response predates a confirmed assignment. +let clock = 0; +let assignSeq = 0; +let closeSeq = 0; + +const message = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +export default function App() { + const [user, setUser] = useState("guest"); + const [listReq, setListReq] = useState({ filter: "open" }); + const [listRes, setListRes] = useState(null); + const [sel, setSel] = useState(null); + const [detRes, setDetRes] = useState(null); + const [pending, setPending] = useState([]); + const [confirmed, setConfirmed] = useState({}); + const [assignState, setAssignState] = useState(null); + const [closing, setClosing] = useState<{ seq: number; id: number }[]>([]); + const [closed, setClosed] = useState>({}); + const [closeState, setCloseState] = useState(null); + + useEffect(() => { + let cancelled = false; + const startedAt = ++clock; + fetchIssues(listReq.filter).then( + data => { if (!cancelled) setListRes({ req: listReq, startedAt, ok: true, data }); }, + (e: unknown) => { if (!cancelled) setListRes({ req: listReq, startedAt, ok: false, error: message(e) }); }, + ); + return () => { cancelled = true; }; + }, [listReq]); + + useEffect(() => { + if (!sel) return; + let cancelled = false; + fetchIssue(sel.id).then( + data => { if (!cancelled) setDetRes({ req: sel, ok: true, data }); }, + (e: unknown) => { if (!cancelled) setDetRes({ req: sel, ok: false, error: message(e) }); }, + ); + return () => { cancelled = true; }; + }, [sel]); + + const chooseFilter = (filter: Filter) => { + if (filter !== listReq.filter) setListReq({ filter }); + }; + + const select = (id: number) => { + if (sel && sel.id === id) return; + setSel({ id }); + setAssignState(null); + setCloseState(null); + }; + + const retry = () => { + if (sel) setSel({ id: sel.id }); + }; + + const doAssign = () => { + if (!sel) return; + const id = sel.id; + const seq = ++assignSeq; + const who = user; + setPending(p => [...p, { seq, id, user: who }]); + setAssignState({ seq, id, error: null }); + assign(id, who).then( + res => { + const at = ++clock; + setConfirmed(c => ({ ...c, [id]: { assignee: res.assignee, at } })); + setPending(p => p.filter(x => x.seq !== seq)); + }, + (e: unknown) => { + setPending(p => p.filter(x => x.seq !== seq)); + setAssignState(s => (s && s.seq === seq ? { ...s, error: message(e) } : s)); + }, + ); + }; + + const doClose = () => { + if (!sel) return; + const id = sel.id; + const seq = ++closeSeq; + setClosing(c => [...c, { seq, id }]); + setCloseState({ seq, id, error: null }); + closeIssue(id).then( + () => { + setClosed(c => ({ ...c, [id]: true })); + setClosing(c => c.filter(x => x.seq !== seq)); + }, + (e: unknown) => { + setClosing(c => c.filter(x => x.seq !== seq)); + setCloseState(s => (s && s.seq === seq ? { ...s, error: message(e) } : s)); + }, + ); + }; + + const isHidden = (id: number) => closed[id] === true || closing.some(c => c.id === id); + + const assigneeOf = (row: Issue, startedAt: number): string | null => { + let latest: Pending | null = null; + for (const p of pending) if (p.id === row.id && (!latest || p.seq > latest.seq)) latest = p; + if (latest) return latest.user; + const c = confirmed[row.id]; + if (c && c.at > startedAt) return c.assignee; + return row.assignee; + }; + + const filter = listReq.filter; + let list: React.ReactNode; + if (!listRes || listRes.req !== listReq) list =

    loading {filter} issues

    ; + else if (!listRes.ok) list =

    list error: {listRes.error}

    ; + else + list = ( +
      + {listRes.data.filter(row => !(filter === "open" && isHidden(row.id))).map(row => { + const a = assigneeOf(row, listRes.startedAt); + return ( +
    • select(row.id)} + style={{ cursor: "pointer", fontWeight: sel?.id === row.id ? "bold" : undefined }} + > + {`#${row.id} ${row.title} (${a ?? "unassigned"})`} +
    • + ); + })} +
    + ); + + let detail: React.ReactNode; + if (!sel) detail =

    select an issue

    ; + else { + let body: React.ReactNode; + if (!detRes || detRes.req !== sel) body =

    loading #{sel.id}

    ; + else if (!detRes.ok) body =

    detail error: {detRes.error}

    ; + else if (!detRes.data) body =

    detail error: issue #{sel.id} not found

    ; + else + body = ( + <> +

    {`#${detRes.data.id} ${detRes.data.title}`}

    +

    {detRes.data.body}

    + + ); + const assignError = assignState && assignState.id === sel.id ? assignState.error : null; + const closeError = closeState && closeState.id === sel.id ? closeState.error : null; + detail = ( + <> + {body} + {" "} + + {assignError !== null &&

    {assignError}

    } + + {closeError !== null &&

    {`close error: ${closeError}`}

    } + + ); + } + + return ( +
    + +
    + {" "} + +
    +
    +
    {list}
    +
    {detail}
    +
    +
    + ); +} diff --git a/eval/results/change-2026-10-28/runs/small/close-react-2/runlog.jsonl b/eval/results/change-2026-10-28/runs/small/close-react-2/runlog.jsonl new file mode 100644 index 0000000..3d09cfc --- /dev/null +++ b/eval/results/change-2026-10-28/runs/small/close-react-2/runlog.jsonl @@ -0,0 +1,2 @@ +{"at":"2026-10-28T05:34:48.591Z","cmd":"check","ok":true,"out":"ok"} +{"at":"2026-10-28T05:34:59.704Z","cmd":"test","ok":true,"build":true,"passed":6,"total":6} diff --git a/eval/results/change-2026-10-28/runs/small/close-react-3/App.tsx b/eval/results/change-2026-10-28/runs/small/close-react-3/App.tsx new file mode 100644 index 0000000..1d495c8 --- /dev/null +++ b/eval/results/change-2026-10-28/runs/small/close-react-3/App.tsx @@ -0,0 +1,210 @@ +import { useEffect, useRef, useState } from "react"; +import { assign, closeIssue, fetchIssue, fetchIssues } from "./api.js"; +import type { Issue, IssueDetail } from "./api.js"; + +type Filter = "open" | "closed"; + +type ListResult = + | { filter: Filter; nonce: number; ok: true; issues: Issue[] } + | { filter: Filter; nonce: number; ok: false; message: string }; + +type DetailResult = + | { id: number; nonce: number; ok: true; issue: IssueDetail | null } + | { id: number; nonce: number; ok: false; message: string }; + +type Pending = { token: number; assignee: string; confirmed: boolean }; + +const errMessage = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +export default function App() { + const [user, setUser] = useState("guest"); + const [filter, setFilter] = useState("open"); + const [listNonce, setListNonce] = useState(0); + const [list, setList] = useState(null); + + const [selectedId, setSelectedId] = useState(null); + const [detailNonce, setDetailNonce] = useState(0); + const [detail, setDetail] = useState(null); + + // Per-issue assignments, oldest first; the latest still standing wins. + const [assignments, setAssignments] = useState>({}); + const [assignError, setAssignError] = useState<{ id: number; token: number; message: string } | null>(null); + const tokenRef = useRef(0); + const latestAssignRef = useRef(0); + const selectedRef = useRef(null); + + // Issues closed in this session, mapped to their latest close attempt; present while the row is out. + const [closing, setClosing] = useState>({}); + const [closeError, setCloseError] = useState<{ id: number; message: string } | null>(null); + const closeTokenRef = useRef(0); + const latestCloseRef = useRef(0); + + useEffect(() => { + let live = true; + fetchIssues(filter).then( + issues => { if (live) setList({ filter, nonce: listNonce, ok: true, issues }); }, + (e: unknown) => { if (live) setList({ filter, nonce: listNonce, ok: false, message: errMessage(e) }); }, + ); + return () => { live = false; }; + }, [filter, listNonce]); + + useEffect(() => { + if (selectedId === null) return; + let live = true; + const id = selectedId; + fetchIssue(id).then( + issue => { if (live) setDetail({ id, nonce: detailNonce, ok: true, issue }); }, + (e: unknown) => { if (live) setDetail({ id, nonce: detailNonce, ok: false, message: errMessage(e) }); }, + ); + return () => { live = false; }; + }, [selectedId, detailNonce]); + + const chooseFilter = (f: Filter) => { + if (f === filter) return; + setFilter(f); + setListNonce(n => n + 1); + }; + + const select = (id: number) => { + if (id === selectedId) return; + selectedRef.current = id; + setSelectedId(id); + setDetailNonce(n => n + 1); + setAssignError(null); + setCloseError(null); + }; + + const retry = () => { + setDetailNonce(n => n + 1); + }; + + const doAssign = () => { + if (selectedId === null) return; + const id = selectedId; + const who = user; + const token = ++tokenRef.current; + latestAssignRef.current = token; + setAssignError(null); + setAssignments(prev => ({ ...prev, [id]: [...(prev[id] ?? []), { token, assignee: who, confirmed: false }] })); + assign(id, who).then( + res => { + setAssignments(prev => { + const entries = prev[id] ?? []; + // A confirmed assignment supersedes older ones. + const idx = entries.findIndex(p => p.token === token); + if (idx < 0) return prev; + const next = entries.slice(idx).map(p => (p.token === token ? { ...p, assignee: res.assignee, confirmed: true } : p)); + return { ...prev, [id]: next }; + }); + }, + (e: unknown) => { + setAssignments(prev => { + const entries = (prev[id] ?? []).filter(p => p.token !== token); + return { ...prev, [id]: entries }; + }); + if (latestAssignRef.current === token && selectedRef.current === id) { + setAssignError({ id, token, message: errMessage(e) }); + } + }, + ); + }; + + const doClose = () => { + if (selectedId === null) return; + const id = selectedId; + const token = ++closeTokenRef.current; + latestCloseRef.current = token; + setCloseError(null); + setClosing(prev => ({ ...prev, [id]: token })); + closeIssue(id).then( + () => {}, + (e: unknown) => { + // Declined: the row comes back, unless a newer close for this issue has started since. + setClosing(prev => { + if (prev[id] !== token) return prev; + const next = { ...prev }; + delete next[id]; + return next; + }); + if (latestCloseRef.current === token && selectedRef.current === id) { + setCloseError({ id, message: errMessage(e) }); + } + }, + ); + }; + + const shownAssignee = (issue: Issue): string | null => { + const entries = assignments[issue.id]; + if (entries && entries.length) return entries[entries.length - 1].assignee; + return issue.assignee; + }; + + const listCurrent = list !== null && list.filter === filter && list.nonce === listNonce ? list : null; + + // In the open list, closed issues are left out; the closed list is shown as the server gives it. + const shownIssues = listCurrent && listCurrent.ok + ? listCurrent.issues.filter(issue => filter !== "open" || closing[issue.id] === undefined) + : []; + + let listBody; + if (!listCurrent) listBody =

    loading {filter} issues

    ; + else if (!listCurrent.ok) listBody =

    list error: {listCurrent.message}

    ; + else + listBody = ( +
      + {shownIssues.map(issue => ( +
    • select(issue.id)} + style={{ cursor: "pointer", fontWeight: issue.id === selectedId ? "bold" : undefined }} + > + {`#${issue.id} ${issue.title} (${shownAssignee(issue) ?? "unassigned"})`} +
    • + ))} +
    + ); + + let detailBody; + if (selectedId === null) detailBody =

    select an issue

    ; + else { + const current = detail !== null && detail.id === selectedId && detail.nonce === detailNonce ? detail : null; + let content; + if (!current) content =

    loading #{selectedId}

    ; + else if (!current.ok) content =

    detail error: {current.message}

    ; + else if (current.issue === null) content =

    detail error: issue #{selectedId} not found

    ; + else + content = ( +
    +

    {`#${current.issue.id} ${current.issue.title}`}

    +

    {current.issue.body}

    +
    + ); + detailBody = ( + <> + {content} + + + {assignError && assignError.id === selectedId &&

    {assignError.message}

    } + + {closeError && closeError.id === selectedId &&

    {`close error: ${closeError.message}`}

    } + + ); + } + + return ( +
    + +
    + + +
    +
    +
    {listBody}
    +
    {detailBody}
    +
    +
    + ); +} diff --git a/eval/results/change-2026-10-28/runs/small/close-react-3/runlog.jsonl b/eval/results/change-2026-10-28/runs/small/close-react-3/runlog.jsonl new file mode 100644 index 0000000..ba4defb --- /dev/null +++ b/eval/results/change-2026-10-28/runs/small/close-react-3/runlog.jsonl @@ -0,0 +1,3 @@ +{"at":"2026-10-28T05:34:56.665Z","cmd":"check","ok":false,"out":"src/App.tsx(119,5): error TS2304: Cannot find name 'closeIssue'."} +{"at":"2026-10-28T05:35:06.480Z","cmd":"check","ok":true,"out":"ok"} +{"at":"2026-10-28T05:35:17.498Z","cmd":"test","ok":true,"build":true,"passed":6,"total":6} diff --git a/eval/results/change-2026-10-28/runs/small/close-rx-1/main.rx b/eval/results/change-2026-10-28/runs/small/close-rx-1/main.rx new file mode 100644 index 0000000..0b5914c --- /dev/null +++ b/eval/results/change-2026-10-28/runs/small/close-rx-1/main.rx @@ -0,0 +1,91 @@ +import pure { fetchIssues, fetchIssue } from "./api" +import { assign, closeIssue } from "./api" +import pure { indexOf } from "./prelude" + +export error NotFound(id: number) => `issue #${id} not found` + +fn found(it, id: number) => it == null ? throw NotFound(id) : it + +export default component App() { + source user: string := "guest" + source filter: string := "open" + source selected: number | null := null + source issues key id = await fetchIssues(filter) + source assignError: string = "" + source closedIds: number[] = [] + source closeError: string = "" + + action assignTo(id: number, who: string) { + optimistic assignError := "" + try { + let i = indexOf(issues, t => t.id == id) + optimistic issues[i].assignee := who + await assign(id, who) + refresh issues + } catch (e) { + assignError := e.message + } + } + + action closeOne(id: number) { + let before = closedIds + optimistic closeError := "" + optimistic closedIds := [...before, id] + try { + await closeIssue(id) + closedIds := [...before, id] + } catch (e) { + closeError := e.message + } + } + +
    +
    + +
    +
    + + +
    +
    + {loading on(filter) fallback {

    loading {latest filter} issues

    } { + {errored fallback(e) {

    list error: {e.message}

    } { + shown = issues.filter(it => filter != "open" || !closedIds.includes(it.id)) +
      + {for it in shown { +
    • { + selected := it.id + onFlush(() => { assignError := ""; closeError := "" }) + }}>#{it.id} {it.title} ({it.assignee ?? "unassigned"})
    • + }} +
    + }} + }} +
    +
    + {if selected != null { + detail = found(await fetchIssue(selected), selected) + {loading on(selected) fallback {

    loading #{latest selected}

    } { + {errored fallback(e) {

    detail error: {e.message}

    } { +

    #{detail.id} {detail.title}

    +

    {detail.body}

    + }} + + }} + + {if assignError != "" { +

    {assignError}

    + }} + + {if closeError != "" { +

    close error: {closeError}

    + }} + } else { +

    select an issue

    + }} +
    +
    +} diff --git a/eval/results/change-2026-10-28/runs/small/close-rx-1/runlog.jsonl b/eval/results/change-2026-10-28/runs/small/close-rx-1/runlog.jsonl new file mode 100644 index 0000000..b4e155b --- /dev/null +++ b/eval/results/change-2026-10-28/runs/small/close-rx-1/runlog.jsonl @@ -0,0 +1,3 @@ +{"at":"2026-10-28T05:35:09.563Z","cmd":"check","ok":true,"out":"src/main.rx: ok"} +{"at":"2026-10-28T05:35:26.229Z","cmd":"test","ok":true,"build":true,"passed":6,"total":6} +{"at":"2026-10-28T05:35:58.179Z","cmd":"check","ok":true,"out":"src/main.rx: ok"} diff --git a/eval/results/change-2026-10-28/runs/small/close-rx-2/main.rx b/eval/results/change-2026-10-28/runs/small/close-rx-2/main.rx new file mode 100644 index 0000000..4cbebad --- /dev/null +++ b/eval/results/change-2026-10-28/runs/small/close-rx-2/main.rx @@ -0,0 +1,93 @@ +import pure { fetchIssues, fetchIssue } from "./api" +import { assign, closeIssue } from "./api" +import pure { indexOf, filter as keep } from "./prelude" + +export error NotFound(id: number) => `issue #${id} not found` +fn found(it, id: number) => it == null ? throw NotFound(id) : it + +type AssignError = { id: number; message: string } +type CloseError = { id: number; message: string } + +export default component App() { + source user: string := "guest" + source filter: string := "open" + source selectedId: number | null := null + source assignError: AssignError | null := null + source closeError: CloseError | null := null + source issues key id = await fetchIssues(filter) + + detail = selectedId == null ? null : found(await fetchIssue(selectedId), selectedId) + + action assignTo(id: number, who: string) { + try { + let i = indexOf(issues, t => t.id == id) + optimistic issues[i].assignee := who + await assign(id, who) + refresh issues + } catch (e) { + assignError := { id: id, message: e.message } + } + } + + action closeTo(id: number) { + try { + optimistic issues := keep(issues, t => t.id != id) + await closeIssue(id) + refresh issues + } catch (e) { + closeError := { id: id, message: e.message } + } + } + +
    + +
    + + +
    +
      + {loading on(filter) fallback {
    • loading {latest filter} issues
    • } { + {errored fallback(e) {
    • list error: {e.message}
    • } { + {for it in issues { +
    • { + onFlush(() => { assignError := null; closeError := null }) + selectedId := it.id + }}>#{it.id} {it.title} ({it.assignee ?? "unassigned"})
    • + }} + }} + }} +
    +
    + {if selectedId == null { +

    select an issue

    + } else { + {loading on(selectedId) fallback {

    loading #{latest selectedId}

    } { + {errored fallback(e) {

    detail error: {e.message}

    } { + {if detail { +

    #{detail.id} {detail.title}

    +

    {detail.body}

    + }} + }} + }} + + + + {if assignError != null && assignError.id == selectedId { +

    {assignError.message}

    + }} + {if closeError != null && closeError.id == selectedId { +

    close error: {closeError.message}

    + }} + }} +
    +
    +} diff --git a/eval/results/change-2026-10-28/runs/small/close-rx-2/runlog.jsonl b/eval/results/change-2026-10-28/runs/small/close-rx-2/runlog.jsonl new file mode 100644 index 0000000..c870d71 --- /dev/null +++ b/eval/results/change-2026-10-28/runs/small/close-rx-2/runlog.jsonl @@ -0,0 +1,2 @@ +{"at":"2026-10-28T05:35:08.384Z","cmd":"check","ok":true,"out":"src/main.rx: ok"} +{"at":"2026-10-28T05:35:25.884Z","cmd":"test","ok":true,"build":true,"passed":6,"total":6} diff --git a/eval/results/change-2026-10-28/runs/small/close-rx-3/main.rx b/eval/results/change-2026-10-28/runs/small/close-rx-3/main.rx new file mode 100644 index 0000000..29e8fc4 --- /dev/null +++ b/eval/results/change-2026-10-28/runs/small/close-rx-3/main.rx @@ -0,0 +1,92 @@ +import pure { fetchIssues, fetchIssue } from "./api" +import { assign, closeIssue } from "./api" +import pure { indexOf, filter as keep } from "./prelude" + +export error NotFound(id: number) => `issue #${id} not found` + +fn found(it, id) => it == null ? throw NotFound(id) : it + +fn without(xs: number[], id: number) => keep(xs, c => c != id) + +export default component App() { + source user: string := "guest" + source filter: string := "open" + source selected: number | null := null + source issues key id = await fetchIssues(filter) + source assignError: { id: number; message: string } | null := null + source closedIds: number[] := [] + source closeError: { id: number; message: string } | null := null + + detail = selected == null ? null : found(await fetchIssue(selected), selected) + shownError = assignError != null && assignError.id == selected ? assignError.message : "" + shownCloseError = closeError != null && closeError.id == selected ? closeError.message : "" + + action assignTo(id: number, who: string) { + try { + let i = indexOf(issues, t => t.id == id) + if (i >= 0) { + optimistic issues[i].assignee := who + } + await assign(id, who) + refresh issues + } catch (e) { + assignError := { id: id, message: e.message } + } + } + + action closeOne(id: number) { + try { + await closeIssue(id) + } catch (e) { + closedIds := without(closedIds, id) + closeError := { id: id, message: e.message } + } + } + +
    + +
    + + +
    +
    +
      + {loading on(filter) fallback {
    • loading {filter} issues
    • } { + {errored fallback(e) {
    • list error: {e.message}
    • } { + {for it in issues { + {if filter != "open" || indexOf(closedIds, c => c == it.id) < 0 { +
    • { + selected := it.id + onFlush(() => { assignError := null; closeError := null }) + }}>#{it.id} {it.title} ({it.assignee ?? "unassigned"})
    • + }} + }} + }} + }} +
    +
    + {if selected == null { +

    select an issue

    + } else { + {loading on(selected) fallback {

    loading #{selected}

    } { + {errored fallback(e) {

    detail error: {e.message}

    } { +

    #{detail.id} {detail.title}

    +

    {detail.body}

    + }} + }} + + + + {if shownError != "" {

    {shownError}

    }} + {if shownCloseError != "" {

    close error: {shownCloseError}

    }} + }} +
    +
    +
    +} diff --git a/eval/results/change-2026-10-28/runs/small/close-rx-3/runlog.jsonl b/eval/results/change-2026-10-28/runs/small/close-rx-3/runlog.jsonl new file mode 100644 index 0000000..f6fedd3 --- /dev/null +++ b/eval/results/change-2026-10-28/runs/small/close-rx-3/runlog.jsonl @@ -0,0 +1,3 @@ +{"at":"2026-10-28T05:35:27.179Z","cmd":"check","ok":false,"out":"/home/claude/runs/change-small/close-rx-3/src/main.rx:13: error SHADOWED_IMPORT [MO-1d] filter shadows the imported filter: rename one (`import { filter as … }`)"} +{"at":"2026-10-28T05:35:32.698Z","cmd":"check","ok":true,"out":"src/main.rx: ok"} +{"at":"2026-10-28T05:35:42.561Z","cmd":"test","ok":true,"build":true,"passed":6,"total":6} diff --git a/eval/results/change-2026-10-28/runs/small/comments-react-1/App.tsx b/eval/results/change-2026-10-28/runs/small/comments-react-1/App.tsx new file mode 100644 index 0000000..edc1325 --- /dev/null +++ b/eval/results/change-2026-10-28/runs/small/comments-react-1/App.tsx @@ -0,0 +1,259 @@ +import { useRef, useState } from "react"; +import { QueryClient, QueryClientProvider, useQuery, useQueryClient } from "@tanstack/react-query"; +import { addComment, assign, fetchComments, fetchIssue, fetchIssues } from "./api.js"; +import type { Comment, Issue } from "./api.js"; + +type Filter = "open" | "closed"; +type Pending = { seq: number; user: string }; +type AssignError = { issueId: number; message: string }; +type PendingComment = { seq: number; author: string; text: string }; +type PostError = { issueId: number; message: string }; + +const errorMessage = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +function Tracker() { + const queryClient = useQueryClient(); + const [user, setUser] = useState("guest"); + const [filter, setFilter] = useState("open"); + const [selected, setSelected] = useState(null); + // optimistic assignments still in the air, per issue, oldest first + const [pending, setPending] = useState>({}); + const [assignError, setAssignError] = useState(null); + const seqRef = useRef(0); + const confirmedSeqRef = useRef>({}); + // optimistic comments still in the air, per issue, oldest first + const [pendingComments, setPendingComments] = useState>({}); + const [commentText, setCommentText] = useState(""); + const [postError, setPostError] = useState(null); + const commentSeqRef = useRef(0); + + const list = useQuery({ + queryKey: ["issues", filter], + queryFn: () => fetchIssues(filter), + }); + + const detail = useQuery({ + queryKey: ["issue", selected], + queryFn: async () => { + const id = selected as number; + const d = await fetchIssue(id); + if (d === null) throw new Error(`issue #${id} not found`); + return d; + }, + enabled: selected !== null, + }); + + const comments = useQuery({ + queryKey: ["comments", selected], + queryFn: () => fetchComments(selected as number), + enabled: selected !== null, + }); + + const assigneeOf = (issue: Issue) => { + const p = pending[issue.id]; + if (p && p.length > 0) return p[p.length - 1].user; + return issue.assignee; + }; + + const select = (id: number) => { + setSelected(id); + setAssignError(null); + setPostError(null); + }; + + const doAssign = async (id: number, who: string) => { + const seq = ++seqRef.current; + setAssignError(null); + setPending(prev => ({ ...prev, [id]: [...(prev[id] ?? []), { seq, user: who }] })); + const done = () => + setPending(prev => { + const rest = (prev[id] ?? []).filter(p => p.seq !== seq); + const next = { ...prev }; + if (rest.length) next[id] = rest; + else delete next[id]; + return next; + }); + try { + const res = await assign(id, who); + if ((confirmedSeqRef.current[id] ?? 0) < seq) { + confirmedSeqRef.current[id] = seq; + queryClient.setQueriesData({ queryKey: ["issues"] }, old => + old?.map(i => (i.id === id ? { ...i, assignee: res.assignee } : i)), + ); + } + done(); + } catch (e) { + done(); + // only report the failure if no later assignment has started since + if (seqRef.current === seq) setAssignError({ issueId: id, message: errorMessage(e) }); + } + }; + + const doPost = async (id: number, author: string, text: string) => { + const seq = ++commentSeqRef.current; + setPostError(null); + setCommentText(""); + setPendingComments(prev => ({ ...prev, [id]: [...(prev[id] ?? []), { seq, author, text }] })); + const done = () => + setPendingComments(prev => { + const rest = (prev[id] ?? []).filter(p => p.seq !== seq); + const next = { ...prev }; + if (rest.length) next[id] = rest; + else delete next[id]; + return next; + }); + const key = ["comments", id]; + try { + const res = await addComment(id, author, text); + if (queryClient.getQueryData(key) !== undefined) { + queryClient.setQueryData(key, old => [...(old ?? []), res]); + done(); + } else { + // the comments are not loaded here: take them from the server instead + done(); + void queryClient.invalidateQueries({ queryKey: key }); + } + } catch (e) { + done(); + // only report the failure if no later post has started since + if (commentSeqRef.current === seq) setPostError({ issueId: id, message: errorMessage(e) }); + } + }; + + const post = () => { + if (selected === null || commentText.trim() === "") return; + void doPost(selected, user, commentText); + }; + + let listContent; + if (list.isPending) listContent =

    loading {filter} issues

    ; + else if (list.isError) listContent =

    list error: {errorMessage(list.error)}

    ; + else + listContent = ( +
      + {list.data.map(issue => ( +
    • + +
    • + ))} +
    + ); + + let detailContent; + if (selected === null) detailContent =

    select an issue

    ; + else { + let body; + if (detail.isFetching || detail.isPending) body =

    loading #{selected}

    ; + else if (detail.isError) body =

    detail error: {errorMessage(detail.error)}

    ; + else + body = ( + <> +

    + #{detail.data.id} {detail.data.title} +

    +

    {detail.data.body}

    + + ); + const id = selected; + const commentRows = [ + ...(comments.data ?? []).map((c, i) => ( +
  • + {c.author}: {c.text} +
  • + )), + ...(pendingComments[id] ?? []).map(p => ( +
  • + {p.author}: {p.text} (sending…) +
  • + )), + ]; + detailContent = ( + <> + {body} + + + {assignError && assignError.issueId === selected && ( +

    {assignError.message}

    + )} +
    + {comments.isPending ? ( +

    loading comments

    + ) : comments.isError ? ( +

    comments error: {errorMessage(comments.error)}

    + ) : null} +
      {commentRows}
    +
    +
    + setCommentText(e.target.value)} + /> + +
    + {postError && postError.issueId === selected && ( +

    post error: {postError.message}

    + )} + + ); + } + + return ( +
    +
    + +
    + + +
    +
    {listContent}
    +
    +
    {detailContent}
    +
    + ); +} + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, refetchOnWindowFocus: false, refetchOnReconnect: false }, + }, +}); + +export default function App() { + return ( + + + + ); +} diff --git a/eval/results/change-2026-10-28/runs/small/comments-react-1/runlog.jsonl b/eval/results/change-2026-10-28/runs/small/comments-react-1/runlog.jsonl new file mode 100644 index 0000000..bb9afc8 --- /dev/null +++ b/eval/results/change-2026-10-28/runs/small/comments-react-1/runlog.jsonl @@ -0,0 +1,2 @@ +{"at":"2026-10-28T05:35:23.706Z","cmd":"check","ok":true,"out":"ok"} +{"at":"2026-10-28T05:35:36.009Z","cmd":"test","ok":true,"build":true,"passed":6,"total":6} diff --git a/eval/results/change-2026-10-28/runs/small/comments-react-2/App.tsx b/eval/results/change-2026-10-28/runs/small/comments-react-2/App.tsx new file mode 100644 index 0000000..fbb7d53 --- /dev/null +++ b/eval/results/change-2026-10-28/runs/small/comments-react-2/App.tsx @@ -0,0 +1,236 @@ +import { useEffect, useState } from "react"; +import { addComment, assign, fetchComments, fetchIssue, fetchIssues } from "./api.js"; +import type { Comment, Issue, IssueDetail } from "./api.js"; + +type Filter = "open" | "closed"; +type ListReq = { filter: Filter }; +type ListRes = + | { req: ListReq; startedAt: number; ok: true; data: Issue[] } + | { req: ListReq; startedAt: number; ok: false; error: string }; +type SelReq = { id: number }; +type DetRes = + | { req: SelReq; ok: true; data: IssueDetail | null } + | { req: SelReq; ok: false; error: string }; +type Pending = { seq: number; id: number; user: string }; +type Confirmed = Record; +type AssignState = { seq: number; id: number; error: string | null } | null; +type CommentsRes = + | { id: number; startedAt: number; ok: true; data: Comment[] } + | { id: number; startedAt: number; ok: false; error: string }; +// A post on the server's comment list: `sentAt` is set once the server has accepted it. +type Post = { seq: number; id: number; author: string; text: string; sentAt: number | null }; +type PostError = { id: number; message: string } | null; + +// Logical clock used to tell whether a list response predates a confirmed assignment, +// and whether a comments response was requested after a post was accepted. +let clock = 0; +let assignSeq = 0; +let postSeq = 0; + +const message = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +export default function App() { + const [user, setUser] = useState("guest"); + const [listReq, setListReq] = useState({ filter: "open" }); + const [listRes, setListRes] = useState(null); + const [sel, setSel] = useState(null); + const [detRes, setDetRes] = useState(null); + const [pending, setPending] = useState([]); + const [confirmed, setConfirmed] = useState({}); + const [assignState, setAssignState] = useState(null); + const [commentsRes, setCommentsRes] = useState(null); + const [posts, setPosts] = useState([]); + const [draft, setDraft] = useState(""); + const [postError, setPostError] = useState(null); + + useEffect(() => { + let cancelled = false; + const startedAt = ++clock; + fetchIssues(listReq.filter).then( + data => { if (!cancelled) setListRes({ req: listReq, startedAt, ok: true, data }); }, + (e: unknown) => { if (!cancelled) setListRes({ req: listReq, startedAt, ok: false, error: message(e) }); }, + ); + return () => { cancelled = true; }; + }, [listReq]); + + useEffect(() => { + if (!sel) return; + let cancelled = false; + fetchIssue(sel.id).then( + data => { if (!cancelled) setDetRes({ req: sel, ok: true, data }); }, + (e: unknown) => { if (!cancelled) setDetRes({ req: sel, ok: false, error: message(e) }); }, + ); + return () => { cancelled = true; }; + }, [sel]); + + useEffect(() => { + if (!sel) return; + let cancelled = false; + const id = sel.id; + const startedAt = ++clock; + fetchComments(id).then( + data => { if (!cancelled) setCommentsRes({ id, startedAt, ok: true, data }); }, + (e: unknown) => { if (!cancelled) setCommentsRes({ id, startedAt, ok: false, error: message(e) }); }, + ); + return () => { cancelled = true; }; + }, [sel]); + + const chooseFilter = (filter: Filter) => { + if (filter !== listReq.filter) setListReq({ filter }); + }; + + const select = (id: number) => { + if (sel && sel.id === id) return; + setSel({ id }); + setAssignState(null); + setPostError(null); + }; + + const retry = () => { + if (sel) setSel({ id: sel.id }); + }; + + const doAssign = () => { + if (!sel) return; + const id = sel.id; + const seq = ++assignSeq; + const who = user; + setPending(p => [...p, { seq, id, user: who }]); + setAssignState({ seq, id, error: null }); + assign(id, who).then( + res => { + const at = ++clock; + setConfirmed(c => ({ ...c, [id]: { assignee: res.assignee, at } })); + setPending(p => p.filter(x => x.seq !== seq)); + }, + (e: unknown) => { + setPending(p => p.filter(x => x.seq !== seq)); + setAssignState(s => (s && s.seq === seq ? { ...s, error: message(e) } : s)); + }, + ); + }; + + const doPost = () => { + if (!sel || draft.trim() === "") return; + const id = sel.id; + const author = user; + const text = draft; + const seq = ++postSeq; + setPosts(p => [...p, { seq, id, author, text, sentAt: null }]); + setDraft(""); + setPostError(null); + addComment(id, author, text).then( + () => { + const sentAt = ++clock; + setPosts(p => p.map(x => (x.seq === seq ? { ...x, sentAt } : x))); + }, + (e: unknown) => { + setPosts(p => p.filter(x => x.seq !== seq)); + setPostError({ id, message: message(e) }); + }, + ); + }; + + const assigneeOf = (row: Issue, startedAt: number): string | null => { + let latest: Pending | null = null; + for (const p of pending) if (p.id === row.id && (!latest || p.seq > latest.seq)) latest = p; + if (latest) return latest.user; + const c = confirmed[row.id]; + if (c && c.at > startedAt) return c.assignee; + return row.assignee; + }; + + const filter = listReq.filter; + let list: React.ReactNode; + if (!listRes || listRes.req !== listReq) list =

    loading {filter} issues

    ; + else if (!listRes.ok) list =

    list error: {listRes.error}

    ; + else + list = ( +
      + {listRes.data.map(row => { + const a = assigneeOf(row, listRes.startedAt); + return ( +
    • select(row.id)} + style={{ cursor: "pointer", fontWeight: sel?.id === row.id ? "bold" : undefined }} + > + {`#${row.id} ${row.title} (${a ?? "unassigned"})`} +
    • + ); + })} +
    + ); + + let detail: React.ReactNode; + if (!sel) detail =

    select an issue

    ; + else { + let body: React.ReactNode; + if (!detRes || detRes.req !== sel) body =

    loading #{sel.id}

    ; + else if (!detRes.ok) body =

    detail error: {detRes.error}

    ; + else if (!detRes.data) body =

    detail error: issue #{sel.id} not found

    ; + else + body = ( + <> +

    {`#${detRes.data.id} ${detRes.data.title}`}

    +

    {detRes.data.body}

    + + ); + const assignError = assignState && assignState.id === sel.id ? assignState.error : null; + + // Comments: the fetched list for this issue, then this issue's posts not yet covered by a fetch. + const cr = commentsRes && commentsRes.id === sel.id ? commentsRes : null; + const fetched = cr && cr.ok ? cr.data : []; + // A sent post is dropped once a comments fetch requested after it was sent has succeeded, + // because that fetch already includes it. + const covered = (p: Post) => p.sentAt !== null && cr !== null && cr.ok && cr.startedAt > p.sentAt; + const shown = posts.filter(p => p.id === sel.id && !covered(p)); + const commentsStatus = !cr ? "loading comments" : !cr.ok ? `comments error: ${cr.error}` : null; + + detail = ( + <> + {body} + {" "} + + {assignError !== null &&

    {assignError}

    } +
    + {commentsStatus !== null &&

    {commentsStatus}

    } +
      + {fetched.map((c, i) => ( +
    • {`${c.author}: ${c.text}`}
    • + ))} + {shown.map(p => ( +
    • + {`${p.author}: ${p.text}${p.sentAt === null ? " (sending…)" : ""}`} +
    • + ))} +
    +
    + {postError !== null && postError.id === sel.id && ( +

    {`post error: ${postError.message}`}

    + )} +
    + setDraft(e.target.value)} />{" "} + +
    + + ); + } + + return ( +
    + +
    + {" "} + +
    +
    +
    {list}
    +
    {detail}
    +
    +
    + ); +} diff --git a/eval/results/change-2026-10-28/runs/small/comments-react-2/runlog.jsonl b/eval/results/change-2026-10-28/runs/small/comments-react-2/runlog.jsonl new file mode 100644 index 0000000..c3f11d9 --- /dev/null +++ b/eval/results/change-2026-10-28/runs/small/comments-react-2/runlog.jsonl @@ -0,0 +1,2 @@ +{"at":"2026-10-28T05:35:36.889Z","cmd":"check","ok":true,"out":"ok"} +{"at":"2026-10-28T05:35:47.453Z","cmd":"test","ok":true,"build":true,"passed":6,"total":6} diff --git a/eval/results/change-2026-10-28/runs/small/comments-react-3/App.tsx b/eval/results/change-2026-10-28/runs/small/comments-react-3/App.tsx new file mode 100644 index 0000000..cdcde70 --- /dev/null +++ b/eval/results/change-2026-10-28/runs/small/comments-react-3/App.tsx @@ -0,0 +1,261 @@ +import { useEffect, useRef, useState } from "react"; +import { addComment, assign, fetchComments, fetchIssue, fetchIssues } from "./api.js"; +import type { Comment, Issue, IssueDetail } from "./api.js"; + +type Filter = "open" | "closed"; + +type ListResult = + | { filter: Filter; nonce: number; ok: true; issues: Issue[] } + | { filter: Filter; nonce: number; ok: false; message: string }; + +type DetailResult = + | { id: number; nonce: number; ok: true; issue: IssueDetail | null } + | { id: number; nonce: number; ok: false; message: string }; + +type Pending = { token: number; assignee: string; confirmed: boolean }; + +type CommentsResult = + | { id: number; ok: true; comments: Comment[] } + | { id: number; ok: false; message: string }; + +type Post = { token: number; author: string; text: string; status: "sending" | "accepted" }; + +const errMessage = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +export default function App() { + const [user, setUser] = useState("guest"); + const [filter, setFilter] = useState("open"); + const [listNonce, setListNonce] = useState(0); + const [list, setList] = useState(null); + + const [selectedId, setSelectedId] = useState(null); + const [detailNonce, setDetailNonce] = useState(0); + const [detail, setDetail] = useState(null); + + // Per-issue assignments, oldest first; the latest still standing wins. + const [assignments, setAssignments] = useState>({}); + const [assignError, setAssignError] = useState<{ id: number; token: number; message: string } | null>(null); + const tokenRef = useRef(0); + const latestAssignRef = useRef(0); + const selectedRef = useRef(null); + + // Comments of the selected issue, and posts made from this page (per issue, oldest first). + const [comments, setComments] = useState(null); + const [posts, setPosts] = useState>({}); + const [draft, setDraft] = useState(""); + const [postError, setPostError] = useState<{ id: number; message: string } | null>(null); + const postTokenRef = useRef(0); + const latestPostRef = useRef(0); + + useEffect(() => { + let live = true; + fetchIssues(filter).then( + issues => { if (live) setList({ filter, nonce: listNonce, ok: true, issues }); }, + (e: unknown) => { if (live) setList({ filter, nonce: listNonce, ok: false, message: errMessage(e) }); }, + ); + return () => { live = false; }; + }, [filter, listNonce]); + + useEffect(() => { + if (selectedId === null) return; + let live = true; + const id = selectedId; + fetchIssue(id).then( + issue => { if (live) setDetail({ id, nonce: detailNonce, ok: true, issue }); }, + (e: unknown) => { if (live) setDetail({ id, nonce: detailNonce, ok: false, message: errMessage(e) }); }, + ); + return () => { live = false; }; + }, [selectedId, detailNonce]); + + useEffect(() => { + if (selectedId === null) return; + let live = true; + const id = selectedId; + fetchComments(id).then( + list => { if (live) setComments({ id, ok: true, comments: list }); }, + (e: unknown) => { if (live) setComments({ id, ok: false, message: errMessage(e) }); }, + ); + return () => { live = false; }; + }, [selectedId]); + + const chooseFilter = (f: Filter) => { + if (f === filter) return; + setFilter(f); + setListNonce(n => n + 1); + }; + + const select = (id: number) => { + if (id === selectedId) return; + selectedRef.current = id; + setSelectedId(id); + setDetailNonce(n => n + 1); + setAssignError(null); + setComments(null); + setPostError(null); + }; + + const retry = () => { + setDetailNonce(n => n + 1); + }; + + const doAssign = () => { + if (selectedId === null) return; + const id = selectedId; + const who = user; + const token = ++tokenRef.current; + latestAssignRef.current = token; + setAssignError(null); + setAssignments(prev => ({ ...prev, [id]: [...(prev[id] ?? []), { token, assignee: who, confirmed: false }] })); + assign(id, who).then( + res => { + setAssignments(prev => { + const entries = prev[id] ?? []; + // A confirmed assignment supersedes older ones. + const idx = entries.findIndex(p => p.token === token); + if (idx < 0) return prev; + const next = entries.slice(idx).map(p => (p.token === token ? { ...p, assignee: res.assignee, confirmed: true } : p)); + return { ...prev, [id]: next }; + }); + }, + (e: unknown) => { + setAssignments(prev => { + const entries = (prev[id] ?? []).filter(p => p.token !== token); + return { ...prev, [id]: entries }; + }); + if (latestAssignRef.current === token && selectedRef.current === id) { + setAssignError({ id, token, message: errMessage(e) }); + } + }, + ); + }; + + const doPost = () => { + if (selectedId === null) return; + const text = draft; + if (text.trim() === "") return; + const id = selectedId; + const author = user; + const token = ++postTokenRef.current; + latestPostRef.current = token; + setPostError(null); + setDraft(""); + setPosts(prev => ({ ...prev, [id]: [...(prev[id] ?? []), { token, author, text, status: "sending" }] })); + addComment(id, author, text).then( + () => { + setPosts(prev => ({ + ...prev, + [id]: (prev[id] ?? []).map(p => (p.token === token ? { ...p, status: "accepted" as const } : p)), + })); + }, + (e: unknown) => { + setPosts(prev => ({ ...prev, [id]: (prev[id] ?? []).filter(p => p.token !== token) })); + if (latestPostRef.current === token && selectedRef.current === id) { + setPostError({ id, message: errMessage(e) }); + } + }, + ); + }; + + const shownAssignee = (issue: Issue): string | null => { + const entries = assignments[issue.id]; + if (entries && entries.length) return entries[entries.length - 1].assignee; + return issue.assignee; + }; + + const listCurrent = list !== null && list.filter === filter && list.nonce === listNonce ? list : null; + + let listBody; + if (!listCurrent) listBody =

    loading {filter} issues

    ; + else if (!listCurrent.ok) listBody =

    list error: {listCurrent.message}

    ; + else + listBody = ( +
      + {listCurrent.issues.map(issue => ( +
    • select(issue.id)} + style={{ cursor: "pointer", fontWeight: issue.id === selectedId ? "bold" : undefined }} + > + {`#${issue.id} ${issue.title} (${shownAssignee(issue) ?? "unassigned"})`} +
    • + ))} +
    + ); + + let detailBody; + if (selectedId === null) detailBody =

    select an issue

    ; + else { + const current = detail !== null && detail.id === selectedId && detail.nonce === detailNonce ? detail : null; + let content; + if (!current) content =

    loading #{selectedId}

    ; + else if (!current.ok) content =

    detail error: {current.message}

    ; + else if (current.issue === null) content =

    detail error: issue #{selectedId} not found

    ; + else + content = ( +
    +

    {`#${current.issue.id} ${current.issue.title}`}

    +

    {current.issue.body}

    +
    + ); + const commentsNow = comments !== null && comments.id === selectedId ? comments : null; + const fetched: Comment[] = commentsNow?.ok ? commentsNow.comments : []; + // An accepted post the server's list already shows is not repeated. + const unmatched = new Map(); + for (const c of fetched) { + const key = JSON.stringify([c.author, c.text]); + unmatched.set(key, (unmatched.get(key) ?? 0) + 1); + } + const shownPosts = (posts[selectedId] ?? []).filter(p => { + if (p.status === "sending") return true; + const key = JSON.stringify([p.author, p.text]); + const n = unmatched.get(key) ?? 0; + if (n > 0) { unmatched.set(key, n - 1); return false; } + return true; + }); + + detailBody = ( + <> + {content} + + + {assignError && assignError.id === selectedId &&

    {assignError.message}

    } +
    + {commentsNow === null &&

    loading comments

    } + {commentsNow !== null && !commentsNow.ok &&

    {`comments error: ${commentsNow.message}`}

    } +
      + {fetched.map((c, i) => ( +
    • {`${c.author}: ${c.text}`}
    • + ))} + {shownPosts.map(p => ( +
    • + {`${p.author}: ${p.text}${p.status === "sending" ? " (sending…)" : ""}`} +
    • + ))} +
    +
    +
    + setDraft(e.target.value)} /> + +
    + {postError && postError.id === selectedId &&

    {`post error: ${postError.message}`}

    } + + ); + } + + return ( +
    + +
    + + +
    +
    +
    {listBody}
    +
    {detailBody}
    +
    +
    + ); +} diff --git a/eval/results/change-2026-10-28/runs/small/comments-react-3/runlog.jsonl b/eval/results/change-2026-10-28/runs/small/comments-react-3/runlog.jsonl new file mode 100644 index 0000000..48a159e --- /dev/null +++ b/eval/results/change-2026-10-28/runs/small/comments-react-3/runlog.jsonl @@ -0,0 +1,2 @@ +{"at":"2026-10-28T05:35:35.646Z","cmd":"check","ok":true,"out":"ok"} +{"at":"2026-10-28T05:35:45.999Z","cmd":"test","ok":true,"build":true,"passed":6,"total":6} diff --git a/eval/results/change-2026-10-28/runs/small/comments-rx-1/main.rx b/eval/results/change-2026-10-28/runs/small/comments-rx-1/main.rx new file mode 100644 index 0000000..3428ea9 --- /dev/null +++ b/eval/results/change-2026-10-28/runs/small/comments-rx-1/main.rx @@ -0,0 +1,108 @@ +import pure { fetchIssues, fetchIssue, fetchComments } from "./api" +import { assign, addComment } from "./api" +import pure { indexOf } from "./prelude" + +export error NotFound(id: number) => `issue #${id} not found` + +fn found(it, id: number) => it == null ? throw NotFound(id) : it + +export default component App() { + source user: string := "guest" + source filter: string := "open" + source selected: number | null := null + source issues key id = await fetchIssues(filter) + source assignError: string = "" + source comments = selected == null ? [] : await fetchComments(selected) + source draft: string = "" + source postError: string = "" + + action assignTo(id: number, who: string) { + optimistic assignError := "" + try { + let i = indexOf(issues, t => t.id == id) + optimistic issues[i].assignee := who + await assign(id, who) + refresh issues + } catch (e) { + assignError := e.message + } + } + + action postComment(id: number, who: string, text: string) { + optimistic postError := "" + try { + optimistic comments := [...comments, { author: who, text: text, sending: true }] + await addComment(id, who, text) + refresh comments + } catch (e) { + postError := e.message + } + } + +
    +
    + +
    +
    + + +
    +
    + {loading on(filter) fallback {

    loading {latest filter} issues

    } { + {errored fallback(e) {

    list error: {e.message}

    } { +
      + {for it in issues { +
    • { + selected := it.id + onFlush(() => { assignError := ""; postError := "" }) + }}>#{it.id} {it.title} ({it.assignee ?? "unassigned"})
    • + }} +
    + }} + }} +
    +
    + {if selected != null { + detail = found(await fetchIssue(selected), selected) + {loading on(selected) fallback {

    loading #{latest selected}

    } { + {errored fallback(e) {

    detail error: {e.message}

    } { +

    #{detail.id} {detail.title}

    +

    {detail.body}

    + }} + + }} + + {if assignError != "" { +

    {assignError}

    + }} +
    + {loading on(selected) fallback {

    loading comments

    } { + {errored fallback(e) {

    comments error: {e.message}

    } { +
      + {for c in comments { +
    • {c.author}: {c.text}{c.sending ? " (sending…)" : ""}
    • + }} +
    + }} + }} +
    + { draft := e.target.value }} /> + + {if postError != "" { +

    post error: {postError}

    + }} + } else { +

    select an issue

    + }} +
    +
    +} diff --git a/eval/results/change-2026-10-28/runs/small/comments-rx-1/runlog.jsonl b/eval/results/change-2026-10-28/runs/small/comments-rx-1/runlog.jsonl new file mode 100644 index 0000000..86b274e --- /dev/null +++ b/eval/results/change-2026-10-28/runs/small/comments-rx-1/runlog.jsonl @@ -0,0 +1,3 @@ +{"at":"2026-10-28T05:35:43.518Z","cmd":"check","ok":false,"out":"/home/claude/runs/change-small/comments-rx-1/src/main.rx:31: error UNCAUGHT_ACTION_FAILURE [R-f-6] action postComment can fail with Error and does not catch it: a handler cannot (it does not await the action) — `try { … } catch (e) { … }` in the body"} +{"at":"2026-10-28T05:35:48.495Z","cmd":"check","ok":true,"out":"src/main.rx: ok"} +{"at":"2026-10-28T05:35:58.287Z","cmd":"test","ok":true,"build":true,"passed":6,"total":6} diff --git a/eval/results/change-2026-10-28/runs/small/comments-rx-2/main.rx b/eval/results/change-2026-10-28/runs/small/comments-rx-2/main.rx new file mode 100644 index 0000000..0c6c997 --- /dev/null +++ b/eval/results/change-2026-10-28/runs/small/comments-rx-2/main.rx @@ -0,0 +1,116 @@ +import pure { fetchIssues, fetchIssue, fetchComments } from "./api" +import { assign, addComment } from "./api" +import pure { indexOf } from "./prelude" + +export error NotFound(id: number) => `issue #${id} not found` +fn found(it, id: number) => it == null ? throw NotFound(id) : it + +type AssignError = { id: number; message: string } + +export default component App() { + source user: string := "guest" + source filter: string := "open" + source selectedId: number | null := null + source assignError: AssignError | null := null + source issues key id = await fetchIssues(filter) + + detail = selectedId == null ? null : found(await fetchIssue(selectedId), selectedId) + + source draft: string := "" + source postError: string | null := null + source comments = selectedId == null ? [] : await fetchComments(selectedId) + + action assignTo(id: number, who: string) { + try { + let i = indexOf(issues, t => t.id == id) + optimistic issues[i].assignee := who + await assign(id, who) + refresh issues + } catch (e) { + assignError := { id: id, message: e.message } + } + } + + action postComment(id: number, author: string, text: string) { + try { + let before = comments + optimistic comments := [...before, { author: author, text: text, sending: true }] + await addComment(id, author, text) + if (selectedId == id) { + comments := [...before, { author: author, text: text, sending: false }] + } + } catch (e) { + if (selectedId == id) { + postError := e.message + } + } + } + +
    + +
    + + +
    +
      + {loading on(filter) fallback {
    • loading {latest filter} issues
    • } { + {errored fallback(e) {
    • list error: {e.message}
    • } { + {for it in issues { +
    • { + onFlush(() => { assignError := null; postError := null }) + selectedId := it.id + }}>#{it.id} {it.title} ({it.assignee ?? "unassigned"})
    • + }} + }} + }} +
    +
    + {if selectedId == null { +

    select an issue

    + } else { + {loading on(selectedId) fallback {

    loading #{latest selectedId}

    } { + {errored fallback(e) {

    detail error: {e.message}

    } { + {if detail { +

    #{detail.id} {detail.title}

    +

    {detail.body}

    + }} + }} + }} + + + {if assignError != null && assignError.id == selectedId { +

    {assignError.message}

    + }} +
    + {loading on(selectedId) fallback {

    loading comments

    } { + {errored fallback(e) {

    comments error: {e.message}

    } { +
      + {for c in comments { +
    • {c.author}: {c.text}{c.sending ? " (sending…)" : ""}
    • + }} +
    + }} + }} +
    + { draft := ev.target.value }} /> + + {if postError != null { +

    post error: {postError}

    + }} + }} +
    +
    +} diff --git a/eval/results/change-2026-10-28/runs/small/comments-rx-2/runlog.jsonl b/eval/results/change-2026-10-28/runs/small/comments-rx-2/runlog.jsonl new file mode 100644 index 0000000..8aef80b --- /dev/null +++ b/eval/results/change-2026-10-28/runs/small/comments-rx-2/runlog.jsonl @@ -0,0 +1,2 @@ +{"at":"2026-10-28T05:35:17.148Z","cmd":"check","ok":true,"out":"src/main.rx: ok"} +{"at":"2026-10-28T05:35:30.750Z","cmd":"test","ok":true,"build":true,"passed":6,"total":6} diff --git a/eval/results/change-2026-10-28/runs/small/comments-rx-3/main.rx b/eval/results/change-2026-10-28/runs/small/comments-rx-3/main.rx new file mode 100644 index 0000000..5732d2d --- /dev/null +++ b/eval/results/change-2026-10-28/runs/small/comments-rx-3/main.rx @@ -0,0 +1,105 @@ +import pure { fetchIssues, fetchIssue, fetchComments } from "./api" +import { assign, addComment } from "./api" +import pure { indexOf } from "./prelude" + +export error NotFound(id: number) => `issue #${id} not found` + +fn found(it, id) => it == null ? throw NotFound(id) : it + +export default component App() { + source user: string := "guest" + source filter: string := "open" + source selected: number | null := null + source issues key id = await fetchIssues(filter) + source assignError: { id: number; message: string } | null := null + source comments = selected == null ? [] : await fetchComments(selected) + source draft: string := "" + source postError: { id: number; message: string } | null := null + + detail = selected == null ? null : found(await fetchIssue(selected), selected) + shownError = assignError != null && assignError.id == selected ? assignError.message : "" + shownPostError = postError != null && postError.id == selected ? `post error: ${postError.message}` : "" + + action assignTo(id: number, who: string) { + try { + let i = indexOf(issues, t => t.id == id) + if (i >= 0) { + optimistic issues[i].assignee := who + } + await assign(id, who) + refresh issues + } catch (e) { + assignError := { id: id, message: e.message } + } + } + + action postComment(id: number, who: string, text: string) { + try { + optimistic comments := [...comments, { author: who, text: text, sending: true }] + await addComment(id, who, text) + refresh comments + } catch (e) { + postError := { id: id, message: e.message } + } + } + +
    + +
    + + +
    +
    +
      + {loading on(filter) fallback {
    • loading {filter} issues
    • } { + {errored fallback(e) {
    • list error: {e.message}
    • } { + {for it in issues { +
    • { + selected := it.id + onFlush(() => { assignError := null; postError := null }) + }}>#{it.id} {it.title} ({it.assignee ?? "unassigned"})
    • + }} + }} + }} +
    +
    + {if selected == null { +

    select an issue

    + } else { + {loading on(selected) fallback {

    loading #{selected}

    } { + {errored fallback(e) {

    detail error: {e.message}

    } { +

    #{detail.id} {detail.title}

    +

    {detail.body}

    + }} + }} + + + {if shownError != "" {

    {shownError}

    }} +
    + {loading on(selected) fallback {

    loading comments

    } { + {errored fallback(e) {

    comments error: {e.message}

    } { +
      + {for c in comments { +
    • {c.author}: {c.text}{c.sending ? " (sending…)" : ""}
    • + }} +
    + }} + }} +
    + { draft := ev.target.value }} /> + + {if shownPostError != "" {

    {shownPostError}

    }} + }} +
    +
    +
    +} diff --git a/eval/results/change-2026-10-28/runs/small/comments-rx-3/runlog.jsonl b/eval/results/change-2026-10-28/runs/small/comments-rx-3/runlog.jsonl new file mode 100644 index 0000000..aba6844 --- /dev/null +++ b/eval/results/change-2026-10-28/runs/small/comments-rx-3/runlog.jsonl @@ -0,0 +1,3 @@ +{"at":"2026-10-28T05:35:48.558Z","cmd":"check","ok":true,"out":"src/main.rx: ok"} +{"at":"2026-10-28T05:35:58.684Z","cmd":"test","ok":true,"build":true,"passed":6,"total":6} +{"at":"2026-10-28T05:36:21.285Z","cmd":"check","ok":true,"out":"src/main.rx: ok"} diff --git a/eval/results/change-2026-10-28/runs/small/rename-react-1/App.tsx b/eval/results/change-2026-10-28/runs/small/rename-react-1/App.tsx new file mode 100644 index 0000000..2e25e5e --- /dev/null +++ b/eval/results/change-2026-10-28/runs/small/rename-react-1/App.tsx @@ -0,0 +1,239 @@ +import { useRef, useState } from "react"; +import { QueryClient, QueryClientProvider, useQuery, useQueryClient } from "@tanstack/react-query"; +import { assign, fetchIssue, fetchIssues, renameIssue } from "./api.js"; +import type { Issue, IssueDetail } from "./api.js"; + +type Filter = "open" | "closed"; +type Pending = { seq: number; user: string }; +type PendingTitle = { seq: number; title: string }; +type AssignError = { issueId: number; message: string }; +type RenameError = { issueId: number; message: string }; + +const errorMessage = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +function Tracker() { + const queryClient = useQueryClient(); + const [user, setUser] = useState("guest"); + const [filter, setFilter] = useState("open"); + const [selected, setSelected] = useState(null); + // optimistic assignments still in the air, per issue, oldest first + const [pending, setPending] = useState>({}); + const [assignError, setAssignError] = useState(null); + // optimistic renames still in the air, per issue, oldest first + const [renaming, setRenaming] = useState>({}); + const [renameError, setRenameError] = useState(null); + const [draft, setDraft] = useState(""); + const seqRef = useRef(0); + const confirmedSeqRef = useRef>({}); + const renameSeqRef = useRef(0); + const renameConfirmedSeqRef = useRef>({}); + + const list = useQuery({ + queryKey: ["issues", filter], + queryFn: () => fetchIssues(filter), + }); + + const detail = useQuery({ + queryKey: ["issue", selected], + queryFn: async () => { + const id = selected as number; + const d = await fetchIssue(id); + if (d === null) throw new Error(`issue #${id} not found`); + return d; + }, + enabled: selected !== null, + }); + + const assigneeOf = (issue: Issue) => { + const p = pending[issue.id]; + if (p && p.length > 0) return p[p.length - 1].user; + return issue.assignee; + }; + + const titleOf = (id: number, title: string) => { + const p = renaming[id]; + if (p && p.length > 0) return p[p.length - 1].title; + return title; + }; + + const select = (id: number) => { + setSelected(id); + setAssignError(null); + setRenameError(null); + setDraft(""); + }; + + const doRename = async (id: number, title: string) => { + const seq = ++renameSeqRef.current; + setRenameError(null); + setRenaming(prev => ({ ...prev, [id]: [...(prev[id] ?? []), { seq, title }] })); + const done = () => + setRenaming(prev => { + const rest = (prev[id] ?? []).filter(p => p.seq !== seq); + const next = { ...prev }; + if (rest.length) next[id] = rest; + else delete next[id]; + return next; + }); + try { + const res = await renameIssue(id, title); + if ((renameConfirmedSeqRef.current[id] ?? 0) < seq) { + renameConfirmedSeqRef.current[id] = seq; + queryClient.setQueriesData({ queryKey: ["issues"] }, old => + old?.map(i => (i.id === id ? { ...i, title: res.title } : i)), + ); + queryClient.setQueryData(["issue", id], old => + old ? { ...old, title: res.title } : old, + ); + } + done(); + } catch (e) { + done(); + // only report the failure if no later rename has started since + if (renameSeqRef.current === seq) setRenameError({ issueId: id, message: errorMessage(e) }); + } + }; + + const doAssign = async (id: number, who: string) => { + const seq = ++seqRef.current; + setAssignError(null); + setPending(prev => ({ ...prev, [id]: [...(prev[id] ?? []), { seq, user: who }] })); + const done = () => + setPending(prev => { + const rest = (prev[id] ?? []).filter(p => p.seq !== seq); + const next = { ...prev }; + if (rest.length) next[id] = rest; + else delete next[id]; + return next; + }); + try { + const res = await assign(id, who); + if ((confirmedSeqRef.current[id] ?? 0) < seq) { + confirmedSeqRef.current[id] = seq; + queryClient.setQueriesData({ queryKey: ["issues"] }, old => + old?.map(i => (i.id === id ? { ...i, assignee: res.assignee } : i)), + ); + } + done(); + } catch (e) { + done(); + // only report the failure if no later assignment has started since + if (seqRef.current === seq) setAssignError({ issueId: id, message: errorMessage(e) }); + } + }; + + let listContent; + if (list.isPending) listContent =

    loading {filter} issues

    ; + else if (list.isError) listContent =

    list error: {errorMessage(list.error)}

    ; + else + listContent = ( +
      + {list.data.map(issue => ( +
    • + +
    • + ))} +
    + ); + + let detailContent; + if (selected === null) detailContent =

    select an issue

    ; + else { + let body; + if (detail.isFetching || detail.isPending) body =

    loading #{selected}

    ; + else if (detail.isError) body =

    detail error: {errorMessage(detail.error)}

    ; + else + body = ( + <> +

    + #{detail.data.id} {titleOf(detail.data.id, detail.data.title)} +

    +

    {detail.data.body}

    + + ); + const id = selected; + detailContent = ( + <> + {body} + + + {assignError && assignError.issueId === selected && ( +

    {assignError.message}

    + )} + setDraft(e.target.value)} + /> + + {renameError && renameError.issueId === selected && ( +

    rename error: {renameError.message}

    + )} + + ); + } + + return ( +
    +
    + +
    + + +
    +
    {listContent}
    +
    +
    {detailContent}
    +
    + ); +} + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, refetchOnWindowFocus: false, refetchOnReconnect: false }, + }, +}); + +export default function App() { + return ( + + + + ); +} diff --git a/eval/results/change-2026-10-28/runs/small/rename-react-1/runlog.jsonl b/eval/results/change-2026-10-28/runs/small/rename-react-1/runlog.jsonl new file mode 100644 index 0000000..1be1ae2 --- /dev/null +++ b/eval/results/change-2026-10-28/runs/small/rename-react-1/runlog.jsonl @@ -0,0 +1,2 @@ +{"at":"2026-10-28T05:35:03.311Z","cmd":"check","ok":true,"out":"ok"} +{"at":"2026-10-28T05:35:18.262Z","cmd":"test","ok":true,"build":true,"passed":6,"total":6} diff --git a/eval/results/change-2026-10-28/runs/small/rename-react-2/App.tsx b/eval/results/change-2026-10-28/runs/small/rename-react-2/App.tsx new file mode 100644 index 0000000..bead5f8 --- /dev/null +++ b/eval/results/change-2026-10-28/runs/small/rename-react-2/App.tsx @@ -0,0 +1,205 @@ +import { useEffect, useState } from "react"; +import { assign, fetchIssue, fetchIssues, renameIssue } from "./api.js"; +import type { Issue, IssueDetail } from "./api.js"; + +type Filter = "open" | "closed"; +type ListReq = { filter: Filter }; +type ListRes = + | { req: ListReq; startedAt: number; ok: true; data: Issue[] } + | { req: ListReq; startedAt: number; ok: false; error: string }; +type SelReq = { id: number }; +type DetRes = + | { req: SelReq; ok: true; data: IssueDetail | null } + | { req: SelReq; ok: false; error: string }; +type Pending = { seq: number; id: number; user: string }; +type Confirmed = Record; +type AssignState = { seq: number; id: number; error: string | null } | null; +type Rename = { seq: number; id: number; title: string }; +type RenameState = { seq: number; id: number; error: string | null } | null; + +// Logical clock used to tell whether a list response predates a confirmed assignment. +let clock = 0; +let assignSeq = 0; +let renameSeq = 0; + +const message = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +export default function App() { + const [user, setUser] = useState("guest"); + const [listReq, setListReq] = useState({ filter: "open" }); + const [listRes, setListRes] = useState(null); + const [sel, setSel] = useState(null); + const [detRes, setDetRes] = useState(null); + const [pending, setPending] = useState([]); + const [confirmed, setConfirmed] = useState({}); + const [assignState, setAssignState] = useState(null); + const [titleDraft, setTitleDraft] = useState(""); + const [renamePending, setRenamePending] = useState([]); + const [renamed, setRenamed] = useState>({}); + const [renameState, setRenameState] = useState(null); + + useEffect(() => { + let cancelled = false; + const startedAt = ++clock; + fetchIssues(listReq.filter).then( + data => { if (!cancelled) setListRes({ req: listReq, startedAt, ok: true, data }); }, + (e: unknown) => { if (!cancelled) setListRes({ req: listReq, startedAt, ok: false, error: message(e) }); }, + ); + return () => { cancelled = true; }; + }, [listReq]); + + useEffect(() => { + if (!sel) return; + let cancelled = false; + fetchIssue(sel.id).then( + data => { if (!cancelled) setDetRes({ req: sel, ok: true, data }); }, + (e: unknown) => { if (!cancelled) setDetRes({ req: sel, ok: false, error: message(e) }); }, + ); + return () => { cancelled = true; }; + }, [sel]); + + const chooseFilter = (filter: Filter) => { + if (filter !== listReq.filter) setListReq({ filter }); + }; + + const select = (id: number) => { + if (sel && sel.id === id) return; + setSel({ id }); + setAssignState(null); + setRenameState(null); + setTitleDraft(""); + }; + + const retry = () => { + if (sel) setSel({ id: sel.id }); + }; + + const doAssign = () => { + if (!sel) return; + const id = sel.id; + const seq = ++assignSeq; + const who = user; + setPending(p => [...p, { seq, id, user: who }]); + setAssignState({ seq, id, error: null }); + assign(id, who).then( + res => { + const at = ++clock; + setConfirmed(c => ({ ...c, [id]: { assignee: res.assignee, at } })); + setPending(p => p.filter(x => x.seq !== seq)); + }, + (e: unknown) => { + setPending(p => p.filter(x => x.seq !== seq)); + setAssignState(s => (s && s.seq === seq ? { ...s, error: message(e) } : s)); + }, + ); + }; + + const doRename = () => { + if (!sel || titleDraft.trim() === "") return; + const id = sel.id; + const title = titleDraft; + const seq = ++renameSeq; + setRenamePending(p => [...p, { seq, id, title }]); + setRenameState({ seq, id, error: null }); + renameIssue(id, title).then( + res => { + setRenamed(r => ({ ...r, [id]: res.title })); + setRenamePending(p => p.filter(x => x.seq !== seq)); + }, + (e: unknown) => { + setRenamePending(p => p.filter(x => x.seq !== seq)); + setRenameState(s => (s && s.seq === seq ? { ...s, error: message(e) } : s)); + }, + ); + }; + + const titleOf = (id: number, base: string): string => { + let latest: Rename | null = null; + for (const p of renamePending) if (p.id === id && (!latest || p.seq > latest.seq)) latest = p; + if (latest) return latest.title; + return renamed[id] ?? base; + }; + + const assigneeOf = (row: Issue, startedAt: number): string | null => { + let latest: Pending | null = null; + for (const p of pending) if (p.id === row.id && (!latest || p.seq > latest.seq)) latest = p; + if (latest) return latest.user; + const c = confirmed[row.id]; + if (c && c.at > startedAt) return c.assignee; + return row.assignee; + }; + + const filter = listReq.filter; + let list: React.ReactNode; + if (!listRes || listRes.req !== listReq) list =

    loading {filter} issues

    ; + else if (!listRes.ok) list =

    list error: {listRes.error}

    ; + else + list = ( +
      + {listRes.data.map(row => { + const a = assigneeOf(row, listRes.startedAt); + return ( +
    • select(row.id)} + style={{ cursor: "pointer", fontWeight: sel?.id === row.id ? "bold" : undefined }} + > + {`#${row.id} ${titleOf(row.id, row.title)} (${a ?? "unassigned"})`} +
    • + ); + })} +
    + ); + + let detail: React.ReactNode; + if (!sel) detail =

    select an issue

    ; + else { + let body: React.ReactNode; + if (!detRes || detRes.req !== sel) body =

    loading #{sel.id}

    ; + else if (!detRes.ok) body =

    detail error: {detRes.error}

    ; + else if (!detRes.data) body =

    detail error: issue #{sel.id} not found

    ; + else + body = ( + <> +

    {`#${detRes.data.id} ${titleOf(detRes.data.id, detRes.data.title)}`}

    +

    {detRes.data.body}

    + + ); + const assignError = assignState && assignState.id === sel.id ? assignState.error : null; + const renameError = renameState && renameState.id === sel.id ? renameState.error : null; + detail = ( + <> + {body} + {" "} + + {assignError !== null &&

    {assignError}

    } +
    + setTitleDraft(e.target.value)} + />{" "} + +
    + {renameError !== null &&

    {`rename error: ${renameError}`}

    } + + ); + } + + return ( +
    + +
    + {" "} + +
    +
    +
    {list}
    +
    {detail}
    +
    +
    + ); +} diff --git a/eval/results/change-2026-10-28/runs/small/rename-react-2/runlog.jsonl b/eval/results/change-2026-10-28/runs/small/rename-react-2/runlog.jsonl new file mode 100644 index 0000000..2050843 --- /dev/null +++ b/eval/results/change-2026-10-28/runs/small/rename-react-2/runlog.jsonl @@ -0,0 +1,3 @@ +{"at":"2026-10-28T05:34:59.860Z","cmd":"check","ok":true,"out":"ok"} +{"at":"2026-10-28T05:35:09.319Z","cmd":"check","ok":true,"out":"ok"} +{"at":"2026-10-28T05:35:27.001Z","cmd":"test","ok":true,"build":true,"passed":6,"total":6} diff --git a/eval/results/change-2026-10-28/runs/small/rename-react-3/App.tsx b/eval/results/change-2026-10-28/runs/small/rename-react-3/App.tsx new file mode 100644 index 0000000..77c8a6a --- /dev/null +++ b/eval/results/change-2026-10-28/runs/small/rename-react-3/App.tsx @@ -0,0 +1,212 @@ +import { useEffect, useRef, useState } from "react"; +import { assign, fetchIssue, fetchIssues, renameIssue } from "./api.js"; +import type { Issue, IssueDetail } from "./api.js"; + +type Filter = "open" | "closed"; + +type ListResult = + | { filter: Filter; nonce: number; ok: true; issues: Issue[] } + | { filter: Filter; nonce: number; ok: false; message: string }; + +type DetailResult = + | { id: number; nonce: number; ok: true; issue: IssueDetail | null } + | { id: number; nonce: number; ok: false; message: string }; + +type Pending = { token: number; assignee: string; confirmed: boolean }; + +type Renamed = { token: number; title: string }; + +const errMessage = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +export default function App() { + const [user, setUser] = useState("guest"); + const [filter, setFilter] = useState("open"); + const [listNonce, setListNonce] = useState(0); + const [list, setList] = useState(null); + + const [selectedId, setSelectedId] = useState(null); + const [detailNonce, setDetailNonce] = useState(0); + const [detail, setDetail] = useState(null); + + // Per-issue assignments, oldest first; the latest still standing wins. + const [assignments, setAssignments] = useState>({}); + const [assignError, setAssignError] = useState<{ id: number; token: number; message: string } | null>(null); + const tokenRef = useRef(0); + const latestAssignRef = useRef(0); + const selectedRef = useRef(null); + + // Per-issue renames, oldest first; the latest still standing wins. + const [renames, setRenames] = useState>({}); + const [renameDraft, setRenameDraft] = useState(""); + const [renameError, setRenameError] = useState<{ id: number; token: number; message: string } | null>(null); + const latestRenameRef = useRef(0); + + useEffect(() => { + let live = true; + fetchIssues(filter).then( + issues => { if (live) setList({ filter, nonce: listNonce, ok: true, issues }); }, + (e: unknown) => { if (live) setList({ filter, nonce: listNonce, ok: false, message: errMessage(e) }); }, + ); + return () => { live = false; }; + }, [filter, listNonce]); + + useEffect(() => { + if (selectedId === null) return; + let live = true; + const id = selectedId; + fetchIssue(id).then( + issue => { if (live) setDetail({ id, nonce: detailNonce, ok: true, issue }); }, + (e: unknown) => { if (live) setDetail({ id, nonce: detailNonce, ok: false, message: errMessage(e) }); }, + ); + return () => { live = false; }; + }, [selectedId, detailNonce]); + + const chooseFilter = (f: Filter) => { + if (f === filter) return; + setFilter(f); + setListNonce(n => n + 1); + }; + + const select = (id: number) => { + if (id === selectedId) return; + selectedRef.current = id; + setSelectedId(id); + setDetailNonce(n => n + 1); + setAssignError(null); + setRenameDraft(""); + setRenameError(null); + }; + + const retry = () => { + setDetailNonce(n => n + 1); + }; + + const doAssign = () => { + if (selectedId === null) return; + const id = selectedId; + const who = user; + const token = ++tokenRef.current; + latestAssignRef.current = token; + setAssignError(null); + setAssignments(prev => ({ ...prev, [id]: [...(prev[id] ?? []), { token, assignee: who, confirmed: false }] })); + assign(id, who).then( + res => { + setAssignments(prev => { + const entries = prev[id] ?? []; + // A confirmed assignment supersedes older ones. + const idx = entries.findIndex(p => p.token === token); + if (idx < 0) return prev; + const next = entries.slice(idx).map(p => (p.token === token ? { ...p, assignee: res.assignee, confirmed: true } : p)); + return { ...prev, [id]: next }; + }); + }, + (e: unknown) => { + setAssignments(prev => { + const entries = (prev[id] ?? []).filter(p => p.token !== token); + return { ...prev, [id]: entries }; + }); + if (latestAssignRef.current === token && selectedRef.current === id) { + setAssignError({ id, token, message: errMessage(e) }); + } + }, + ); + }; + + const doRename = () => { + if (selectedId === null || renameDraft === "") return; + const id = selectedId; + const title = renameDraft; + const token = ++tokenRef.current; + latestRenameRef.current = token; + setRenameError(null); + setRenames(prev => ({ ...prev, [id]: [...(prev[id] ?? []), { token, title }] })); + renameIssue(id, title).then( + () => { + // Accepted: the new title stays as it is. + }, + (e: unknown) => { + setRenames(prev => ({ ...prev, [id]: (prev[id] ?? []).filter(p => p.token !== token) })); + if (latestRenameRef.current === token && selectedRef.current === id) { + setRenameError({ id, token, message: errMessage(e) }); + } + }, + ); + }; + + const shownAssignee = (issue: Issue): string | null => { + const entries = assignments[issue.id]; + if (entries && entries.length) return entries[entries.length - 1].assignee; + return issue.assignee; + }; + + const shownTitle = (id: number, base: string): string => { + const entries = renames[id]; + if (entries && entries.length) return entries[entries.length - 1].title; + return base; + }; + + const listCurrent = list !== null && list.filter === filter && list.nonce === listNonce ? list : null; + + let listBody; + if (!listCurrent) listBody =

    loading {filter} issues

    ; + else if (!listCurrent.ok) listBody =

    list error: {listCurrent.message}

    ; + else + listBody = ( +
      + {listCurrent.issues.map(issue => ( +
    • select(issue.id)} + style={{ cursor: "pointer", fontWeight: issue.id === selectedId ? "bold" : undefined }} + > + {`#${issue.id} ${shownTitle(issue.id, issue.title)} (${shownAssignee(issue) ?? "unassigned"})`} +
    • + ))} +
    + ); + + let detailBody; + if (selectedId === null) detailBody =

    select an issue

    ; + else { + const current = detail !== null && detail.id === selectedId && detail.nonce === detailNonce ? detail : null; + let content; + if (!current) content =

    loading #{selectedId}

    ; + else if (!current.ok) content =

    detail error: {current.message}

    ; + else if (current.issue === null) content =

    detail error: issue #{selectedId} not found

    ; + else + content = ( +
    +

    {`#${current.issue.id} ${shownTitle(current.issue.id, current.issue.title)}`}

    +

    {current.issue.body}

    +
    + ); + detailBody = ( + <> + {content} + + + {assignError && assignError.id === selectedId &&

    {assignError.message}

    } + setRenameDraft(e.target.value)} /> + + {renameError && renameError.id === selectedId &&

    {`rename error: ${renameError.message}`}

    } + + ); + } + + return ( +
    + +
    + + +
    +
    +
    {listBody}
    +
    {detailBody}
    +
    +
    + ); +} diff --git a/eval/results/change-2026-10-28/runs/small/rename-react-3/runlog.jsonl b/eval/results/change-2026-10-28/runs/small/rename-react-3/runlog.jsonl new file mode 100644 index 0000000..6965547 --- /dev/null +++ b/eval/results/change-2026-10-28/runs/small/rename-react-3/runlog.jsonl @@ -0,0 +1,2 @@ +{"at":"2026-10-28T05:35:05.711Z","cmd":"check","ok":true,"out":"ok"} +{"at":"2026-10-28T05:35:20.079Z","cmd":"test","ok":true,"build":true,"passed":6,"total":6} diff --git a/eval/results/change-2026-10-28/runs/small/rename-rx-1/main.rx b/eval/results/change-2026-10-28/runs/small/rename-rx-1/main.rx new file mode 100644 index 0000000..7698a6f --- /dev/null +++ b/eval/results/change-2026-10-28/runs/small/rename-rx-1/main.rx @@ -0,0 +1,97 @@ +import pure { fetchIssues, fetchIssue } from "./api" +import { assign, renameIssue } from "./api" +import pure { indexOf } from "./prelude" + +export error NotFound(id: number) => `issue #${id} not found` + +fn found(it, id: number) => it == null ? throw NotFound(id) : it + +export default component App() { + source user: string := "guest" + source filter: string := "open" + source selected: number | null := null + source issues key id = await fetchIssues(filter) + source assignError: string = "" + source detail = selected == null ? null : found(await fetchIssue(selected), selected) + source draftTitle: string = "" + source renameError: string = "" + + action assignTo(id: number, who: string) { + optimistic assignError := "" + try { + let i = indexOf(issues, t => t.id == id) + optimistic issues[i].assignee := who + await assign(id, who) + refresh issues + } catch (e) { + assignError := e.message + } + } + + action renameTo(id: number, title: string) { + try { + let i = indexOf(issues, t => t.id == id) + let cur = detail + if (i >= 0) { optimistic issues[i].title := title } + optimistic detail := { id: cur.id, title: title, body: cur.body } + await renameIssue(id, title) + refresh issues + } catch (e) { + renameError := e.message + } + } + +
    +
    + +
    +
    + + +
    +
    + {loading on(filter) fallback {

    loading {latest filter} issues

    } { + {errored fallback(e) {

    list error: {e.message}

    } { +
      + {for it in issues { +
    • { + selected := it.id + onFlush(() => { assignError := ""; renameError := ""; draftTitle := "" }) + }}>#{it.id} {it.title} ({it.assignee ?? "unassigned"})
    • + }} +
    + }} + }} +
    +
    + {if selected != null { + {loading on(selected) fallback {

    loading #{latest selected}

    } { + {errored fallback(e) {

    detail error: {e.message}

    } { +

    #{detail.id} {detail.title}

    +

    {detail.body}

    + }} + + }} + + {if assignError != "" { +

    {assignError}

    + }} + { draftTitle := e.target.value }} /> + + {if renameError != "" { +

    rename error: {renameError}

    + }} + } else { +

    select an issue

    + }} +
    +
    +} diff --git a/eval/results/change-2026-10-28/runs/small/rename-rx-1/runlog.jsonl b/eval/results/change-2026-10-28/runs/small/rename-rx-1/runlog.jsonl new file mode 100644 index 0000000..1fe562a --- /dev/null +++ b/eval/results/change-2026-10-28/runs/small/rename-rx-1/runlog.jsonl @@ -0,0 +1,2 @@ +{"at":"2026-10-28T05:35:08.056Z","cmd":"check","ok":true,"out":"src/main.rx: ok"} +{"at":"2026-10-28T05:35:23.287Z","cmd":"test","ok":true,"build":true,"passed":6,"total":6} diff --git a/eval/results/change-2026-10-28/runs/small/rename-rx-2/main.rx b/eval/results/change-2026-10-28/runs/small/rename-rx-2/main.rx new file mode 100644 index 0000000..8e7b54e --- /dev/null +++ b/eval/results/change-2026-10-28/runs/small/rename-rx-2/main.rx @@ -0,0 +1,102 @@ +import pure { fetchIssues, fetchIssue } from "./api" +import { assign, renameIssue } from "./api" +import pure { indexOf } from "./prelude" + +export error NotFound(id: number) => `issue #${id} not found` +fn found(it, id: number) => it == null ? throw NotFound(id) : it + +type AssignError = { id: number; message: string } +type RenameError = { id: number; message: string } +type Renamed = { id: number; title: string } + +export default component App() { + source user: string := "guest" + source filter: string := "open" + source selectedId: number | null := null + source assignError: AssignError | null := null + source renameError: RenameError | null := null + source draft: string := "" + source renamed: Renamed | null = null + source issues key id = await fetchIssues(filter) + + detail = selectedId == null ? null : found(await fetchIssue(selectedId), selectedId) + + action assignTo(id: number, who: string) { + try { + let i = indexOf(issues, t => t.id == id) + optimistic issues[i].assignee := who + await assign(id, who) + refresh issues + } catch (e) { + assignError := { id: id, message: e.message } + } + } + + action renameTo(id: number, title: string) { + try { + let i = indexOf(issues, t => t.id == id) + if (i >= 0) { optimistic issues[i].title := title } + optimistic renamed := { id: id, title: title } + let answer = await renameIssue(id, title) + refresh issues + renamed := { id: answer.id, title: answer.title } + } catch (e) { + renameError := { id: id, message: e.message } + } + } + +
    + +
    + + +
    +
      + {loading on(filter) fallback {
    • loading {latest filter} issues
    • } { + {errored fallback(e) {
    • list error: {e.message}
    • } { + {for it in issues { +
    • { + onFlush(() => { assignError := null; renameError := null }) + draft := "" + selectedId := it.id + }}>#{it.id} {it.title} ({it.assignee ?? "unassigned"})
    • + }} + }} + }} +
    +
    + {if selectedId == null { +

    select an issue

    + } else { + {loading on(selectedId) fallback {

    loading #{latest selectedId}

    } { + {errored fallback(e) {

    detail error: {e.message}

    } { + {if detail { +

    #{detail.id} {renamed != null && renamed.id == selectedId ? renamed.title : detail.title}

    +

    {detail.body}

    + }} + }} + }} + { draft := ev.target.value }} /> + + {if renameError != null && renameError.id == selectedId { +

    rename error: {renameError.message}

    + }} + + + {if assignError != null && assignError.id == selectedId { +

    {assignError.message}

    + }} + }} +
    +
    +} diff --git a/eval/results/change-2026-10-28/runs/small/rename-rx-2/runlog.jsonl b/eval/results/change-2026-10-28/runs/small/rename-rx-2/runlog.jsonl new file mode 100644 index 0000000..00a4a7b --- /dev/null +++ b/eval/results/change-2026-10-28/runs/small/rename-rx-2/runlog.jsonl @@ -0,0 +1,2 @@ +{"at":"2026-10-28T05:35:09.015Z","cmd":"check","ok":true,"out":"src/main.rx: ok"} +{"at":"2026-10-28T05:35:25.741Z","cmd":"test","ok":true,"build":true,"passed":6,"total":6} diff --git a/eval/results/change-2026-10-28/runs/small/rename-rx-3/main.rx b/eval/results/change-2026-10-28/runs/small/rename-rx-3/main.rx new file mode 100644 index 0000000..a290841 --- /dev/null +++ b/eval/results/change-2026-10-28/runs/small/rename-rx-3/main.rx @@ -0,0 +1,99 @@ +import pure { fetchIssues, fetchIssue } from "./api" +import { assign, renameIssue } from "./api" +import pure { indexOf } from "./prelude" + +export error NotFound(id: number) => `issue #${id} not found` + +fn found(it, id) => it == null ? throw NotFound(id) : it + +export default component App() { + source user: string := "guest" + source filter: string := "open" + source selected: number | null := null + source issues key id = await fetchIssues(filter) + source assignError: { id: number; message: string } | null := null + source renameError: { id: number; message: string } | null := null + source draft: string := "" + source titleGuess: { id: number; title: string } | null = null + + detail = selected == null ? null : found(await fetchIssue(selected), selected) + shownError = assignError != null && assignError.id == selected ? assignError.message : "" + shownRenameError = renameError != null && renameError.id == selected ? renameError.message : "" + + action assignTo(id: number, who: string) { + try { + let i = indexOf(issues, t => t.id == id) + if (i >= 0) { + optimistic issues[i].assignee := who + } + await assign(id, who) + refresh issues + } catch (e) { + assignError := { id: id, message: e.message } + } + } + + action renameTo(id: number, title: string) { + try { + let i = indexOf(issues, t => t.id == id) + optimistic titleGuess := { id: id, title: title } + if (i >= 0) { + optimistic issues[i].title := title + } + await renameIssue(id, title) + titleGuess := { id: id, title: title } + refresh issues + } catch (e) { + renameError := { id: id, message: e.message } + } + } + +
    + +
    + + +
    +
    +
      + {loading on(filter) fallback {
    • loading {filter} issues
    • } { + {errored fallback(e) {
    • list error: {e.message}
    • } { + {for it in issues { +
    • { + selected := it.id + draft := "" + onFlush(() => { assignError := null; renameError := null }) + }}>#{it.id} {it.title} ({it.assignee ?? "unassigned"})
    • + }} + }} + }} +
    +
    + {if selected == null { +

    select an issue

    + } else { + {loading on(selected) fallback {

    loading #{selected}

    } { + {errored fallback(e) {

    detail error: {e.message}

    } { +

    #{detail.id} {titleGuess != null && titleGuess.id == selected ? titleGuess.title : detail.title}

    +

    {detail.body}

    + }} + }} + + + {if shownError != "" {

    {shownError}

    }} + { draft := ev.target.value }} /> + + {if shownRenameError != "" {

    rename error: {shownRenameError}

    }} + }} +
    +
    +
    +} diff --git a/eval/results/change-2026-10-28/runs/small/rename-rx-3/runlog.jsonl b/eval/results/change-2026-10-28/runs/small/rename-rx-3/runlog.jsonl new file mode 100644 index 0000000..e71c463 --- /dev/null +++ b/eval/results/change-2026-10-28/runs/small/rename-rx-3/runlog.jsonl @@ -0,0 +1,4 @@ +{"at":"2026-10-28T05:35:22.425Z","cmd":"check","ok":true,"out":"src/main.rx: ok"} +{"at":"2026-10-28T05:35:33.500Z","cmd":"test","ok":true,"build":true,"passed":6,"total":6} +{"at":"2026-10-28T05:35:50.273Z","cmd":"test","ok":true,"build":true,"passed":7,"total":7} +{"at":"2026-10-28T05:35:54.636Z","cmd":"check","ok":true,"out":"src/main.rx: ok"} diff --git a/eval/results/change-2026-10-28/usage.txt b/eval/results/change-2026-10-28/usage.txt new file mode 100644 index 0000000..d958be2 --- /dev/null +++ b/eval/results/change-2026-10-28/usage.txt @@ -0,0 +1,36 @@ +big close-react-1 tokens=47317 +big close-react-2 tokens=46631 +big close-react-3 tokens=46569 +big close-rx-1 tokens=61646 +big close-rx-2 tokens=53707 +big close-rx-3 tokens=56709 +big comments-react-1 tokens=48908 +big comments-react-2 tokens=48035 +big comments-react-3 tokens=49311 +big comments-rx-1 tokens=60642 +big comments-rx-2 tokens=54838 +big comments-rx-3 tokens=53539 +big rename-react-1 tokens=47571 +big rename-react-2 tokens=48258 +big rename-react-3 tokens=47940 +big rename-rx-1 tokens=49981 +big rename-rx-2 tokens=55965 +big rename-rx-3 tokens=56867 +small close-react-1 tokens=53073 +small close-react-2 tokens=50401 +small close-react-3 tokens=53150 +small close-rx-1 tokens=66506 +small close-rx-2 tokens=75487 +small close-rx-3 tokens=72156 +small comments-react-1 tokens=59091 +small comments-react-2 tokens=66389 +small comments-react-3 tokens=61217 +small comments-rx-1 tokens=77997 +small comments-rx-2 tokens=62321 +small comments-rx-3 tokens=77731 +small rename-react-1 tokens=55775 +small rename-react-2 tokens=54317 +small rename-react-3 tokens=56840 +small rename-rx-1 tokens=60206 +small rename-rx-2 tokens=62482 +small rename-rx-3 tokens=64167 diff --git a/eval/results/cost-2026-10-29/5b/README.md b/eval/results/cost-2026-10-29/5b/README.md new file mode 100644 index 0000000..da6d581 --- /dev/null +++ b/eval/results/cost-2026-10-29/5b/README.md @@ -0,0 +1,75 @@ +# Round 5b results: the guide's effects section + +Pre-registration: `eval/PREREG-cost-5b.md` (a7aa866, b8aefe2). 18 .rx runs on 2026-10-29 with `guide/async.md` §6 +(effects) added. Everything else is as in round 5; React and Solid are round 5's runs. + +## Primary, as pre-registered (H1b not supported) + +| | n | Mean billed | Median | +| --- | --- | --- | --- | +| React (round 5) | 18 | 101,219 | 101,678 | +| .rx (round 5) | 18 | 131,212 | 119,450 | +| .rx (5b) | 18 | 146,228 | 125,939 | +| Solid (round 5) | 18 | 134,953 | 126,145 | + +- H1b (.rx 5b below React): React − .rx 5b = −45,009, p = 0.9998. Not supported. +- H3 (.rx 5b below .rx 5): .rx 5b is 15,016 higher, p = 0.78. Not supported. +- Solid − .rx 5b = −11,275 (p = 0.73). + +## A measurement artifact the pre-registration missed: the prompt cache + +Every run's first model call is the same text (system prompt, tools, the one-line start), about 36.4k tokens. +Whether that call reads it from the prompt cache or writes it depends on whether a run just before warmed the cache, +not on the arm: + +- Round 5's labels batch started cold in all three arms (36.4k written). +- Round 5's related and count batches started warm in all three arms (9.1k written, 27.3k read). +- All 18 of 5b started cold: the session's tool list had changed since round 5, so the prefix was new. + +A cold start costs 27.3k × (1.25 − 0.1) ≈ 31k billed units more. Round 5's comparisons are fair, because each task's +batch ran all three arms at once and so had the same cache state in every arm. 5b against round 5 is not fair: every +5b run carries ~31k that the related and count runs it is compared with don't. Post hoc, `harness/cost-warm.mjs` +drops each run's first call (the same text in every run) and compares the rest (`cost-warm.json`): + +| Mean billed without call 1 | labels | related | count | all | +| --- | --- | --- | --- | --- | +| React | 77,925 | 82,286 | 68,656 | 76,289 | +| .rx (round 5) | 113,122 | 85,208 | 120,540 | 106,290 | +| .rx (5b) | 152,579 | 75,566 | 72,882 | 100,342 | +| Solid | 124,784 | 93,853 | 111,457 | 110,031 | + +- .rx 5b − React = 24,053, p = 0.028 (one-sided, .rx higher). .rx 5 − React = 30,001, p = 0.0015. +- .rx 5 − .rx 5b = 5,947, p = 0.39. + +This is not the pre-registered measure, and it is reported as a correction found after the runs. + +## What changed, by task + +- **count**: the guide answered what agents had gone to the spec for. Runs that touched `docs/spec` or + `docs/guide` from a tool call: 6 of 6 before, 1 of 6 after; model calls 10.2 → 7.0. Cost without call 1: + 120.5k → 72.9k, against React's 68.7k. The gap there is gone. +- **related**: 75.6k against React's 82.3k, .rx's lowest; no doc lookups. +- **labels**: worse (113k → 153k; labels-rx-1 alone 350k, 25 calls). 5 of 6 runs read `spec/05-holds` and + `06-stores` (H-11, AC-3, H-7). All six report the same thing: two adds (or removes) in flight share one + pending-items source, so when the server declines one, it stays on screen until the other ends (H-11). The guide + says to keep pending items in their own source and not to rebuild guesses by hand, and gives no way to guess each + pending item on its own; agents spent turns looking for one, and two tried per-row guesses and backed out. Their + apps pass every hidden test, so the tests don't cover two overlapping adds with one declined. + +## Defects (`score.json`) + +0 of 18. The #4425 test fails in all 6 related runs, as in round 5 (alongside, outside the count). + +## Reading + +The effects section did what it was for: count, where every round 5 run had gone to the spec, now costs about what +React does. Related is level or better. What's left is labels, and that isn't a guide gap. The language has no way to +guess each item of a growing list on its own: append guesses in one source revert together (H-11). That costs turns, +because agents look for a way out, and it leaves a behavior every .rx labels app shares. A per-item guess for +appends, like the per-row guess `todos[i].done` already has (AC-3), is the language change this points to. + +## Files + +`agents.txt`, `cost-rx.txt`, `cost-h1b.json`, `secondary.txt`/`.json`, `cost-warm.json` (with `agents-all.txt`: +round 5 and 5b runs, .rx arms renamed rx5 and rx5b), `score.json`. Combined lists for the tests: `agents-h1b.txt` +(React 5 + .rx 5b), `agents-h2b.txt` (Solid 5 + .rx 5b), `agents-h3.txt` (.rx 5 as "react" + .rx 5b). diff --git a/eval/results/cost-2026-10-29/5b/agents-all.txt b/eval/results/cost-2026-10-29/5b/agents-all.txt new file mode 100644 index 0000000..9fddcb1 --- /dev/null +++ b/eval/results/cost-2026-10-29/5b/agents-all.txt @@ -0,0 +1,72 @@ +labels-rx5-1 ab643b8e389628edd +labels-rx5-2 a2a2814667ce707ef +labels-rx5-3 a59f71b829356b749 +labels-rx5-4 a9feb671f5da817e1 +labels-rx5-5 a48313fd8bb50c698 +labels-rx5-6 aa124395829f42087 +labels-react-1 ac3a12f11d67138b9 +labels-react-2 a7530be4b9aba22fc +labels-react-3 a3ce41aaeae877bca +labels-react-4 abcd5e83e0692da1e +labels-react-5 a3e1cac1749a176a0 +labels-react-6 ad6d11b4cb3fb268e +labels-solid-1 ac1e321ec1e630517 +labels-solid-2 a6ad4db025d03e187 +labels-solid-3 a9a6bddbc84e35f31 +labels-solid-4 afde50adee0e74781 +labels-solid-5 a380a912d0be8a2d0 +labels-solid-6 a57b0c8ae11bb907f +related-rx5-1 ab9d0249acdd9618e +related-rx5-2 ae977bbeaa3b25509 +related-rx5-3 a28b9c005084f5088 +related-rx5-4 abb3f2cdcd00631a3 +related-rx5-5 af82774a607c9edf1 +related-rx5-6 a6026926325a08bd4 +related-react-1 ad66eb3536186dbeb +related-react-2 a495310d65ae60350 +related-react-3 a79dc1d0ac5ef11b4 +related-react-4 a88dcf662aaee2bfb +related-react-5 a4a31a6bbb321800c +related-react-6 a7bb5fb37ce4305d4 +related-solid-1 a73a99f399d904f89 +related-solid-2 ad59891b48506129d +related-solid-3 a0b38f433b04e6d49 +related-solid-4 a90e191e42a946e05 +related-solid-5 a9cab206dc3a87df3 +related-solid-6 a22e55fd95b02aad8 +count-rx5-1 a95c11f4e8a30aba3 +count-rx5-2 a7d5c53afa421a857 +count-rx5-3 a1de92203f2f1f27e +count-rx5-4 a35fd01e61e20d441 +count-rx5-5 ae3d82052f4e50de8 +count-rx5-6 a2ab23ec7e542583d +count-react-1 a8aaf706b924539a2 +count-react-2 a7d205c78ca6fe9f2 +count-react-3 a62abd93538ff02b5 +count-react-4 a6793ee30041cc77f +count-react-5 a384fc841447f85c5 +count-react-6 a3d6e1d10f6af1beb +count-solid-1 a86c370c8f7db960a +count-solid-2 a84a70a1ea36d685b +count-solid-3 ae5dd4435679539c1 +count-solid-4 a1c57189c44fc451a +count-solid-5 a8a8bfaecaaa23e8a +count-solid-6 a8767c8ce1e7ad771 +labels-rx5b-1 a84819d05d26c5a5d +labels-rx5b-2 a37a4a9b78fcf3c7e +labels-rx5b-3 a5bbe0734ef332b70 +labels-rx5b-4 a50c93e7b9807fff0 +labels-rx5b-5 a2a584cdc870dd654 +labels-rx5b-6 a7e06a6ea16068bae +related-rx5b-1 a92b2b3db799c730d +related-rx5b-2 a1600718039ec7683 +related-rx5b-3 af43c33da884a608e +related-rx5b-4 a31185ae33d4acbd1 +related-rx5b-5 aed81440332f8b402 +related-rx5b-6 ac06b77d7a5190c02 +count-rx5b-1 a2f1075a896060f42 +count-rx5b-2 a6da123728f99531a +count-rx5b-3 ae5dd52e3f7831bbd +count-rx5b-4 a60347551dc3de720 +count-rx5b-5 a670401a331df3b08 +count-rx5b-6 a80329d7f9f3d2e76 diff --git a/eval/results/cost-2026-10-29/5b/agents-h1b.txt b/eval/results/cost-2026-10-29/5b/agents-h1b.txt new file mode 100644 index 0000000..c32146e --- /dev/null +++ b/eval/results/cost-2026-10-29/5b/agents-h1b.txt @@ -0,0 +1,36 @@ +labels-react-1 ac3a12f11d67138b9 +labels-react-2 a7530be4b9aba22fc +labels-react-3 a3ce41aaeae877bca +labels-react-4 abcd5e83e0692da1e +labels-react-5 a3e1cac1749a176a0 +labels-react-6 ad6d11b4cb3fb268e +related-react-1 ad66eb3536186dbeb +related-react-2 a495310d65ae60350 +related-react-3 a79dc1d0ac5ef11b4 +related-react-4 a88dcf662aaee2bfb +related-react-5 a4a31a6bbb321800c +related-react-6 a7bb5fb37ce4305d4 +count-react-1 a8aaf706b924539a2 +count-react-2 a7d205c78ca6fe9f2 +count-react-3 a62abd93538ff02b5 +count-react-4 a6793ee30041cc77f +count-react-5 a384fc841447f85c5 +count-react-6 a3d6e1d10f6af1beb +labels-rx-1 a84819d05d26c5a5d +labels-rx-2 a37a4a9b78fcf3c7e +labels-rx-3 a5bbe0734ef332b70 +labels-rx-4 a50c93e7b9807fff0 +labels-rx-5 a2a584cdc870dd654 +labels-rx-6 a7e06a6ea16068bae +related-rx-1 a92b2b3db799c730d +related-rx-2 a1600718039ec7683 +related-rx-3 af43c33da884a608e +related-rx-4 a31185ae33d4acbd1 +related-rx-5 aed81440332f8b402 +related-rx-6 ac06b77d7a5190c02 +count-rx-1 a2f1075a896060f42 +count-rx-2 a6da123728f99531a +count-rx-3 ae5dd52e3f7831bbd +count-rx-4 a60347551dc3de720 +count-rx-5 a670401a331df3b08 +count-rx-6 a80329d7f9f3d2e76 diff --git a/eval/results/cost-2026-10-29/5b/agents-h2b.txt b/eval/results/cost-2026-10-29/5b/agents-h2b.txt new file mode 100644 index 0000000..220b608 --- /dev/null +++ b/eval/results/cost-2026-10-29/5b/agents-h2b.txt @@ -0,0 +1,36 @@ +labels-solid-1 ac1e321ec1e630517 +labels-solid-2 a6ad4db025d03e187 +labels-solid-3 a9a6bddbc84e35f31 +labels-solid-4 afde50adee0e74781 +labels-solid-5 a380a912d0be8a2d0 +labels-solid-6 a57b0c8ae11bb907f +related-solid-1 a73a99f399d904f89 +related-solid-2 ad59891b48506129d +related-solid-3 a0b38f433b04e6d49 +related-solid-4 a90e191e42a946e05 +related-solid-5 a9cab206dc3a87df3 +related-solid-6 a22e55fd95b02aad8 +count-solid-1 a86c370c8f7db960a +count-solid-2 a84a70a1ea36d685b +count-solid-3 ae5dd4435679539c1 +count-solid-4 a1c57189c44fc451a +count-solid-5 a8a8bfaecaaa23e8a +count-solid-6 a8767c8ce1e7ad771 +labels-rx-1 a84819d05d26c5a5d +labels-rx-2 a37a4a9b78fcf3c7e +labels-rx-3 a5bbe0734ef332b70 +labels-rx-4 a50c93e7b9807fff0 +labels-rx-5 a2a584cdc870dd654 +labels-rx-6 a7e06a6ea16068bae +related-rx-1 a92b2b3db799c730d +related-rx-2 a1600718039ec7683 +related-rx-3 af43c33da884a608e +related-rx-4 a31185ae33d4acbd1 +related-rx-5 aed81440332f8b402 +related-rx-6 ac06b77d7a5190c02 +count-rx-1 a2f1075a896060f42 +count-rx-2 a6da123728f99531a +count-rx-3 ae5dd52e3f7831bbd +count-rx-4 a60347551dc3de720 +count-rx-5 a670401a331df3b08 +count-rx-6 a80329d7f9f3d2e76 diff --git a/eval/results/cost-2026-10-29/5b/agents-h3.txt b/eval/results/cost-2026-10-29/5b/agents-h3.txt new file mode 100644 index 0000000..b6af4b8 --- /dev/null +++ b/eval/results/cost-2026-10-29/5b/agents-h3.txt @@ -0,0 +1,36 @@ +labels-react-1 ab643b8e389628edd +labels-react-2 a2a2814667ce707ef +labels-react-3 a59f71b829356b749 +labels-react-4 a9feb671f5da817e1 +labels-react-5 a48313fd8bb50c698 +labels-react-6 aa124395829f42087 +related-react-1 ab9d0249acdd9618e +related-react-2 ae977bbeaa3b25509 +related-react-3 a28b9c005084f5088 +related-react-4 abb3f2cdcd00631a3 +related-react-5 af82774a607c9edf1 +related-react-6 a6026926325a08bd4 +count-react-1 a95c11f4e8a30aba3 +count-react-2 a7d5c53afa421a857 +count-react-3 a1de92203f2f1f27e +count-react-4 a35fd01e61e20d441 +count-react-5 ae3d82052f4e50de8 +count-react-6 a2ab23ec7e542583d +labels-rx-1 a84819d05d26c5a5d +labels-rx-2 a37a4a9b78fcf3c7e +labels-rx-3 a5bbe0734ef332b70 +labels-rx-4 a50c93e7b9807fff0 +labels-rx-5 a2a584cdc870dd654 +labels-rx-6 a7e06a6ea16068bae +related-rx-1 a92b2b3db799c730d +related-rx-2 a1600718039ec7683 +related-rx-3 af43c33da884a608e +related-rx-4 a31185ae33d4acbd1 +related-rx-5 aed81440332f8b402 +related-rx-6 ac06b77d7a5190c02 +count-rx-1 a2f1075a896060f42 +count-rx-2 a6da123728f99531a +count-rx-3 ae5dd52e3f7831bbd +count-rx-4 a60347551dc3de720 +count-rx-5 a670401a331df3b08 +count-rx-6 a80329d7f9f3d2e76 diff --git a/eval/results/cost-2026-10-29/5b/agents.txt b/eval/results/cost-2026-10-29/5b/agents.txt new file mode 100644 index 0000000..a99409e --- /dev/null +++ b/eval/results/cost-2026-10-29/5b/agents.txt @@ -0,0 +1,18 @@ +labels-rx-1 a84819d05d26c5a5d +labels-rx-2 a37a4a9b78fcf3c7e +labels-rx-3 a5bbe0734ef332b70 +labels-rx-4 a50c93e7b9807fff0 +labels-rx-5 a2a584cdc870dd654 +labels-rx-6 a7e06a6ea16068bae +related-rx-1 a92b2b3db799c730d +related-rx-2 a1600718039ec7683 +related-rx-3 af43c33da884a608e +related-rx-4 a31185ae33d4acbd1 +related-rx-5 aed81440332f8b402 +related-rx-6 ac06b77d7a5190c02 +count-rx-1 a2f1075a896060f42 +count-rx-2 a6da123728f99531a +count-rx-3 ae5dd52e3f7831bbd +count-rx-4 a60347551dc3de720 +count-rx-5 a670401a331df3b08 +count-rx-6 a80329d7f9f3d2e76 diff --git a/eval/results/cost-2026-10-29/5b/cost-h1b.json b/eval/results/cost-2026-10-29/5b/cost-h1b.json new file mode 100644 index 0000000..73dc54a --- /dev/null +++ b/eval/results/cost-2026-10-29/5b/cost-h1b.json @@ -0,0 +1,445 @@ +{ + "weights": { + "input": 1, + "cacheWrite": 1.25, + "cacheRead": 0.1, + "output": 5 + }, + "runs": [ + { + "run": "labels-react-1", + "task": "labels", + "arm": "react", + "n": 1, + "input": 14, + "cacheWrite": 59794, + "cacheRead": 285026, + "output": 6138, + "calls": 7, + "billed": 133949.1 + }, + { + "run": "labels-react-2", + "task": "labels", + "arm": "react", + "n": 2, + "input": 12, + "cacheWrite": 55002, + "cacheRead": 220808, + "output": 4564, + "calls": 6, + "billed": 113665.3 + }, + { + "run": "labels-react-3", + "task": "labels", + "arm": "react", + "n": 3, + "input": 14, + "cacheWrite": 58336, + "cacheRead": 285995, + "output": 4473, + "calls": 7, + "billed": 123898.5 + }, + { + "run": "labels-react-4", + "task": "labels", + "arm": "react", + "n": 4, + "input": 14, + "cacheWrite": 55573, + "cacheRead": 275432, + "output": 5171, + "calls": 7, + "billed": 122878.45 + }, + { + "run": "labels-react-5", + "task": "labels", + "arm": "react", + "n": 5, + "input": 14, + "cacheWrite": 57290, + "cacheRead": 281596, + "output": 4598, + "calls": 7, + "billed": 122776.1 + }, + { + "run": "labels-react-6", + "task": "labels", + "arm": "react", + "n": 6, + "input": 14, + "cacheWrite": 58288, + "cacheRead": 283831, + "output": 4908, + "calls": 7, + "billed": 125797.1 + }, + { + "run": "related-react-1", + "task": "related", + "arm": "react", + "n": 1, + "input": 18, + "cacheWrite": 31051, + "cacheRead": 419465, + "output": 4900, + "calls": 9, + "billed": 105278.25 + }, + { + "run": "related-react-2", + "task": "related", + "arm": "react", + "n": 2, + "input": 16, + "cacheWrite": 30477, + "cacheRead": 359747, + "output": 4798, + "calls": 8, + "billed": 98076.95000000001 + }, + { + "run": "related-react-3", + "task": "related", + "arm": "react", + "n": 3, + "input": 16, + "cacheWrite": 28750, + "cacheRead": 357477, + "output": 3742, + "calls": 8, + "billed": 90411.20000000001 + }, + { + "run": "related-react-4", + "task": "related", + "arm": "react", + "n": 4, + "input": 16, + "cacheWrite": 29730, + "cacheRead": 359151, + "output": 4686, + "calls": 8, + "billed": 96523.6 + }, + { + "run": "related-react-5", + "task": "related", + "arm": "react", + "n": 5, + "input": 14, + "cacheWrite": 26107, + "cacheRead": 299006, + "output": 3639, + "calls": 7, + "billed": 80743.35 + }, + { + "run": "related-react-6", + "task": "related", + "arm": "react", + "n": 6, + "input": 16, + "cacheWrite": 32283, + "cacheRead": 365829, + "output": 6479, + "calls": 8, + "billed": 109347.65 + }, + { + "run": "count-react-1", + "task": "count", + "arm": "react", + "n": 1, + "input": 10, + "cacheWrite": 25380, + "cacheRead": 194339, + "output": 4294, + "calls": 5, + "billed": 72638.9 + }, + { + "run": "count-react-2", + "task": "count", + "arm": "react", + "n": 2, + "input": 12, + "cacheWrite": 26411, + "cacheRead": 247919, + "output": 3069, + "calls": 6, + "billed": 73162.65 + }, + { + "run": "count-react-3", + "task": "count", + "arm": "react", + "n": 3, + "input": 12, + "cacheWrite": 24759, + "cacheRead": 247848, + "output": 2344, + "calls": 6, + "billed": 67465.55 + }, + { + "run": "count-react-4", + "task": "count", + "arm": "react", + "n": 4, + "input": 16, + "cacheWrite": 33846, + "cacheRead": 371904, + "output": 9974, + "calls": 8, + "billed": 129383.9 + }, + { + "run": "count-react-5", + "task": "count", + "arm": "react", + "n": 5, + "input": 12, + "cacheWrite": 29445, + "cacheRead": 260999, + "output": 3247, + "calls": 6, + "billed": 79153.15 + }, + { + "run": "count-react-6", + "task": "count", + "arm": "react", + "n": 6, + "input": 14, + "cacheWrite": 26157, + "cacheRead": 300300, + "output": 2812, + "calls": 7, + "billed": 76800.25 + }, + { + "run": "labels-rx-1", + "task": "labels", + "arm": "rx", + "n": 1, + "input": 50, + "cacheWrite": 83083, + "cacheRead": 1560186, + "output": 17986, + "calls": 25, + "billed": 349852.35 + }, + { + "run": "labels-rx-2", + "task": "labels", + "arm": "rx", + "n": 2, + "input": 20, + "cacheWrite": 61177, + "cacheRead": 479613, + "output": 4145, + "calls": 10, + "billed": 145177.55 + }, + { + "run": "labels-rx-3", + "task": "labels", + "arm": "rx", + "n": 3, + "input": 20, + "cacheWrite": 63048, + "cacheRead": 480728, + "output": 4967, + "calls": 10, + "billed": 151737.8 + }, + { + "run": "labels-rx-4", + "task": "labels", + "arm": "rx", + "n": 4, + "input": 20, + "cacheWrite": 64817, + "cacheRead": 489866, + "output": 4114, + "calls": 10, + "billed": 150597.85 + }, + { + "run": "labels-rx-5", + "task": "labels", + "arm": "rx", + "n": 5, + "input": 30, + "cacheWrite": 69198, + "cacheRead": 825198, + "output": 7139, + "calls": 15, + "billed": 204742.3 + }, + { + "run": "labels-rx-6", + "task": "labels", + "arm": "rx", + "n": 6, + "input": 26, + "cacheWrite": 68396, + "cacheRead": 690977, + "output": 6854, + "calls": 13, + "billed": 188888.7 + }, + { + "run": "related-rx-1", + "task": "related", + "arm": "rx", + "n": 1, + "input": 14, + "cacheWrite": 56340, + "cacheRead": 292800, + "output": 2478, + "calls": 7, + "billed": 112109 + }, + { + "run": "related-rx-2", + "task": "related", + "arm": "rx", + "n": 2, + "input": 14, + "cacheWrite": 57587, + "cacheRead": 303054, + "output": 2586, + "calls": 7, + "billed": 115233.15 + }, + { + "run": "related-rx-3", + "task": "related", + "arm": "rx", + "n": 3, + "input": 14, + "cacheWrite": 61148, + "cacheRead": 312177, + "output": 3034, + "calls": 7, + "billed": 122836.7 + }, + { + "run": "related-rx-4", + "task": "related", + "arm": "rx", + "n": 4, + "input": 18, + "cacheWrite": 58932, + "cacheRead": 413951, + "output": 3475, + "calls": 9, + "billed": 132453.1 + }, + { + "run": "related-rx-5", + "task": "related", + "arm": "rx", + "n": 5, + "input": 16, + "cacheWrite": 59307, + "cacheRead": 355985, + "output": 3405, + "calls": 8, + "billed": 126773.25 + }, + { + "run": "related-rx-6", + "task": "related", + "arm": "rx", + "n": 6, + "input": 14, + "cacheWrite": 60036, + "cacheRead": 303405, + "output": 2760, + "calls": 7, + "billed": 119199.5 + }, + { + "run": "count-rx-1", + "task": "count", + "arm": "rx", + "n": 1, + "input": 12, + "cacheWrite": 57169, + "cacheRead": 248043, + "output": 2166, + "calls": 6, + "billed": 107107.55 + }, + { + "run": "count-rx-2", + "task": "count", + "arm": "rx", + "n": 2, + "input": 12, + "cacheWrite": 56767, + "cacheRead": 247274, + "output": 2483, + "calls": 6, + "billed": 108113.15 + }, + { + "run": "count-rx-3", + "task": "count", + "arm": "rx", + "n": 3, + "input": 22, + "cacheWrite": 61496, + "cacheRead": 544187, + "output": 4071, + "calls": 11, + "billed": 151665.7 + }, + { + "run": "count-rx-4", + "task": "count", + "arm": "rx", + "n": 4, + "input": 12, + "cacheWrite": 58927, + "cacheRead": 251810, + "output": 2109, + "calls": 6, + "billed": 109396.75 + }, + { + "run": "count-rx-5", + "task": "count", + "arm": "rx", + "n": 5, + "input": 14, + "cacheWrite": 61695, + "cacheRead": 321863, + "output": 3157, + "calls": 7, + "billed": 125104.05 + }, + { + "run": "count-rx-6", + "task": "count", + "arm": "rx", + "n": 6, + "input": 12, + "cacheWrite": 58940, + "cacheRead": 251642, + "output": 2453, + "calls": 6, + "billed": 111116.2 + } + ], + "diff": -45008.59444444446, + "ratio": 1.444663532058057, + "p": 0.999820001799982 +} diff --git a/eval/results/cost-2026-10-29/5b/cost-rx.txt b/eval/results/cost-2026-10-29/5b/cost-rx.txt new file mode 100644 index 0000000..30820b7 --- /dev/null +++ b/eval/results/cost-2026-10-29/5b/cost-rx.txt @@ -0,0 +1,19 @@ +labels-rx-1 billed 349852 calls 25 out 17986 cacheRead 1560186 cacheWrite 83083 +labels-rx-2 billed 145178 calls 10 out 4145 cacheRead 479613 cacheWrite 61177 +labels-rx-3 billed 151738 calls 10 out 4967 cacheRead 480728 cacheWrite 63048 +labels-rx-4 billed 150598 calls 10 out 4114 cacheRead 489866 cacheWrite 64817 +labels-rx-5 billed 204742 calls 15 out 7139 cacheRead 825198 cacheWrite 69198 +labels-rx-6 billed 188889 calls 13 out 6854 cacheRead 690977 cacheWrite 68396 +related-rx-1 billed 112109 calls 7 out 2478 cacheRead 292800 cacheWrite 56340 +related-rx-2 billed 115233 calls 7 out 2586 cacheRead 303054 cacheWrite 57587 +related-rx-3 billed 122837 calls 7 out 3034 cacheRead 312177 cacheWrite 61148 +related-rx-4 billed 132453 calls 9 out 3475 cacheRead 413951 cacheWrite 58932 +related-rx-5 billed 126773 calls 8 out 3405 cacheRead 355985 cacheWrite 59307 +related-rx-6 billed 119200 calls 7 out 2760 cacheRead 303405 cacheWrite 60036 +count-rx-1 billed 107108 calls 6 out 2166 cacheRead 248043 cacheWrite 57169 +count-rx-2 billed 108113 calls 6 out 2483 cacheRead 247274 cacheWrite 56767 +count-rx-3 billed 151666 calls 11 out 4071 cacheRead 544187 cacheWrite 61496 +count-rx-4 billed 109397 calls 6 out 2109 cacheRead 251810 cacheWrite 58927 +count-rx-5 billed 125104 calls 7 out 3157 cacheRead 321863 cacheWrite 61695 +count-rx-6 billed 111116 calls 6 out 2453 cacheRead 251642 cacheWrite 58940 +rx n 18 mean 146228 sd 57791 diff --git a/eval/results/cost-2026-10-29/5b/cost-warm.json b/eval/results/cost-2026-10-29/5b/cost-warm.json new file mode 100644 index 0000000..ba987e8 --- /dev/null +++ b/eval/results/cost-2026-10-29/5b/cost-warm.json @@ -0,0 +1,546 @@ +{ + "runs": [ + { + "run": "labels-rx5-1", + "task": "labels", + "arm": "rx5", + "first": 45864.5, + "rest": 91999.3 + }, + { + "run": "labels-rx5-2", + "task": "labels", + "arm": "rx5", + "first": 45864.5, + "rest": 71208.85 + }, + { + "run": "labels-rx5-3", + "task": "labels", + "arm": "rx5", + "first": 45864.5, + "rest": 89230.44999999998 + }, + { + "run": "labels-rx5-4", + "task": "labels", + "arm": "rx5", + "first": 45864.5, + "rest": 96483.8 + }, + { + "run": "labels-rx5-5", + "task": "labels", + "arm": "rx5", + "first": 45864.5, + "rest": 72843.35 + }, + { + "run": "labels-rx5-6", + "task": "labels", + "arm": "rx5", + "first": 45864.5, + "rest": 256963.79999999996 + }, + { + "run": "labels-react-1", + "task": "labels", + "arm": "react", + "first": 46123.25, + "rest": 87825.85 + }, + { + "run": "labels-react-2", + "task": "labels", + "arm": "react", + "first": 45858.25, + "rest": 67807.05 + }, + { + "run": "labels-react-3", + "task": "labels", + "arm": "react", + "first": 45858.25, + "rest": 78040.25 + }, + { + "run": "labels-react-4", + "task": "labels", + "arm": "react", + "first": 45858.25, + "rest": 77020.20000000001 + }, + { + "run": "labels-react-5", + "task": "labels", + "arm": "react", + "first": 45858.25, + "rest": 76917.85 + }, + { + "run": "labels-react-6", + "task": "labels", + "arm": "react", + "first": 45858.25, + "rest": 79938.85000000002 + }, + { + "run": "labels-solid-1", + "task": "labels", + "arm": "solid", + "first": 45864.5, + "rest": 79828.5 + }, + { + "run": "labels-solid-2", + "task": "labels", + "arm": "solid", + "first": 45864.5, + "rest": 145814 + }, + { + "run": "labels-solid-3", + "task": "labels", + "arm": "solid", + "first": 45864.5, + "rest": 128603.95000000001 + }, + { + "run": "labels-solid-4", + "task": "labels", + "arm": "solid", + "first": 45864.5, + "rest": 108886.35 + }, + { + "run": "labels-solid-5", + "task": "labels", + "arm": "solid", + "first": 45864.5, + "rest": 137785.15 + }, + { + "run": "labels-solid-6", + "task": "labels", + "arm": "solid", + "first": 45864.5, + "rest": 147785.7 + }, + { + "run": "related-rx5-1", + "task": "related", + "arm": "rx5", + "first": 14450.6, + "rest": 138308.1 + }, + { + "run": "related-rx5-2", + "task": "related", + "arm": "rx5", + "first": 14450.6, + "rest": 78196.05 + }, + { + "run": "related-rx5-3", + "task": "related", + "arm": "rx5", + "first": 14450.6, + "rest": 70269.85 + }, + { + "run": "related-rx5-4", + "task": "related", + "arm": "rx5", + "first": 14450.6, + "rest": 75300.95000000001 + }, + { + "run": "related-rx5-5", + "task": "related", + "arm": "rx5", + "first": 14450.6, + "rest": 73101 + }, + { + "run": "related-rx5-6", + "task": "related", + "arm": "rx5", + "first": 14450.6, + "rest": 76070.5 + }, + { + "run": "related-react-1", + "task": "related", + "arm": "react", + "first": 14444.35, + "rest": 90833.90000000001 + }, + { + "run": "related-react-2", + "task": "related", + "arm": "react", + "first": 14444.35, + "rest": 83632.6 + }, + { + "run": "related-react-3", + "task": "related", + "arm": "react", + "first": 14444.35, + "rest": 75966.84999999999 + }, + { + "run": "related-react-4", + "task": "related", + "arm": "react", + "first": 14444.35, + "rest": 82079.25 + }, + { + "run": "related-react-5", + "task": "related", + "arm": "react", + "first": 14444.35, + "rest": 66299 + }, + { + "run": "related-react-6", + "task": "related", + "arm": "react", + "first": 14444.35, + "rest": 94903.3 + }, + { + "run": "related-solid-1", + "task": "related", + "arm": "solid", + "first": 14450.6, + "rest": 78462.35 + }, + { + "run": "related-solid-2", + "task": "related", + "arm": "solid", + "first": 14450.6, + "rest": 111899.5 + }, + { + "run": "related-solid-3", + "task": "related", + "arm": "solid", + "first": 14450.6, + "rest": 81628.45 + }, + { + "run": "related-solid-4", + "task": "related", + "arm": "solid", + "first": 14450.6, + "rest": 85238.65000000001 + }, + { + "run": "related-solid-5", + "task": "related", + "arm": "solid", + "first": 14450.6, + "rest": 123229.2 + }, + { + "run": "related-solid-6", + "task": "related", + "arm": "solid", + "first": 14450.6, + "rest": 82657.9 + }, + { + "run": "count-rx5-1", + "task": "count", + "arm": "rx5", + "first": 14450.6, + "rest": 209298.15 + }, + { + "run": "count-rx5-2", + "task": "count", + "arm": "rx5", + "first": 14450.6, + "rest": 110782.1 + }, + { + "run": "count-rx5-3", + "task": "count", + "arm": "rx5", + "first": 14450.6, + "rest": 93737.8 + }, + { + "run": "count-rx5-4", + "task": "count", + "arm": "rx5", + "first": 14450.6, + "rest": 115176.95000000001 + }, + { + "run": "count-rx5-5", + "task": "count", + "arm": "rx5", + "first": 14450.6, + "rest": 88502.25 + }, + { + "run": "count-rx5-6", + "task": "count", + "arm": "rx5", + "first": 14450.6, + "rest": 105742.25 + }, + { + "run": "count-react-1", + "task": "count", + "arm": "react", + "first": 14444.35, + "rest": 58194.55 + }, + { + "run": "count-react-2", + "task": "count", + "arm": "react", + "first": 14444.35, + "rest": 58718.3 + }, + { + "run": "count-react-3", + "task": "count", + "arm": "react", + "first": 14444.35, + "rest": 53021.2 + }, + { + "run": "count-react-4", + "task": "count", + "arm": "react", + "first": 14444.35, + "rest": 114939.55000000002 + }, + { + "run": "count-react-5", + "task": "count", + "arm": "react", + "first": 14444.35, + "rest": 64708.8 + }, + { + "run": "count-react-6", + "task": "count", + "arm": "react", + "first": 14444.35, + "rest": 62355.9 + }, + { + "run": "count-solid-1", + "task": "count", + "arm": "solid", + "first": 14450.6, + "rest": 95240.90000000001 + }, + { + "run": "count-solid-2", + "task": "count", + "arm": "solid", + "first": 14450.6, + "rest": 147433.35 + }, + { + "run": "count-solid-3", + "task": "count", + "arm": "solid", + "first": 14450.6, + "rest": 111489.35 + }, + { + "run": "count-solid-4", + "task": "count", + "arm": "solid", + "first": 14450.6, + "rest": 97693.15 + }, + { + "run": "count-solid-5", + "task": "count", + "arm": "solid", + "first": 14450.6, + "rest": 104428.74999999999 + }, + { + "run": "count-solid-6", + "task": "count", + "arm": "solid", + "first": 14450.6, + "rest": 112458.79999999999 + }, + { + "run": "labels-rx5b-1", + "task": "labels", + "arm": "rx5b", + "first": 45874.5, + "rest": 303977.85000000003 + }, + { + "run": "labels-rx5b-2", + "task": "labels", + "arm": "rx5b", + "first": 45874.5, + "rest": 99303.05 + }, + { + "run": "labels-rx5b-3", + "task": "labels", + "arm": "rx5b", + "first": 45874.5, + "rest": 105863.29999999999 + }, + { + "run": "labels-rx5b-4", + "task": "labels", + "arm": "rx5b", + "first": 46149.5, + "rest": 104448.34999999999 + }, + { + "run": "labels-rx5b-5", + "task": "labels", + "arm": "rx5b", + "first": 45874.5, + "rest": 158867.80000000002 + }, + { + "run": "labels-rx5b-6", + "task": "labels", + "arm": "rx5b", + "first": 45874.5, + "rest": 143014.19999999998 + }, + { + "run": "related-rx5b-1", + "task": "related", + "arm": "rx5b", + "first": 45868.25, + "rest": 66240.75 + }, + { + "run": "related-rx5b-2", + "task": "related", + "arm": "rx5b", + "first": 45868.25, + "rest": 69364.9 + }, + { + "run": "related-rx5b-3", + "task": "related", + "arm": "rx5b", + "first": 45868.25, + "rest": 76968.45000000001 + }, + { + "run": "related-rx5b-4", + "task": "related", + "arm": "rx5b", + "first": 45868.25, + "rest": 86584.84999999999 + }, + { + "run": "related-rx5b-5", + "task": "related", + "arm": "rx5b", + "first": 45868.25, + "rest": 80905 + }, + { + "run": "related-rx5b-6", + "task": "related", + "arm": "rx5b", + "first": 45868.25, + "rest": 73331.25 + }, + { + "run": "count-rx5b-1", + "task": "count", + "arm": "rx5b", + "first": 45868.25, + "rest": 61239.3 + }, + { + "run": "count-rx5b-2", + "task": "count", + "arm": "rx5b", + "first": 45868.25, + "rest": 62244.9 + }, + { + "run": "count-rx5b-3", + "task": "count", + "arm": "rx5b", + "first": 45868.25, + "rest": 105797.45000000001 + }, + { + "run": "count-rx5b-4", + "task": "count", + "arm": "rx5b", + "first": 45868.25, + "rest": 63528.50000000001 + }, + { + "run": "count-rx5b-5", + "task": "count", + "arm": "rx5b", + "first": 45868.25, + "rest": 79235.8 + }, + { + "run": "count-rx5b-6", + "task": "count", + "arm": "rx5b", + "first": 45868.25, + "rest": 65247.950000000004 + } + ], + "tests": [ + { + "higher": "rx5", + "lower": "react", + "diff": 30000.680555555562, + "p": 0.0015499845001549985 + }, + { + "higher": "rx5", + "lower": "rx5b", + "diff": 5947.325000000012, + "p": 0.38862611373886263 + }, + { + "higher": "solid", + "lower": "rx5", + "diff": 3741.5833333333285, + "p": 0.4015659843401566 + }, + { + "higher": "solid", + "lower": "react", + "diff": 33742.26388888889, + "p": 0.00000999990000099999 + }, + { + "higher": "solid", + "lower": "rx5b", + "diff": 9688.90833333334, + "p": 0.3030569694303057 + }, + { + "higher": "rx5b", + "lower": "react", + "diff": 24053.35555555555, + "p": 0.02754972450275497 + } + ] +} diff --git a/eval/results/cost-2026-10-29/5b/score.json b/eval/results/cost-2026-10-29/5b/score.json new file mode 100644 index 0000000..313684f --- /dev/null +++ b/eval/results/cost-2026-10-29/5b/score.json @@ -0,0 +1,374 @@ +[ + { + "run": "count-rx-1", + "task": "count", + "arm": "rx", + "n": 1, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "count-rx-2", + "task": "count", + "arm": "rx", + "n": 2, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "count-rx-3", + "task": "count", + "arm": "rx", + "n": 3, + "checks": 1, + "tests": 3, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "count-rx-4", + "task": "count", + "arm": "rx", + "n": 4, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "count-rx-5", + "task": "count", + "arm": "rx", + "n": 5, + "checks": 1, + "tests": 2, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "count-rx-6", + "task": "count", + "arm": "rx", + "n": 6, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "labels-rx-1", + "task": "labels", + "arm": "rx", + "n": 1, + "checks": 9, + "tests": 10, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "22/22", + "hidden": "58/58", + "overlap": "26/26", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "labels-rx-2", + "task": "labels", + "arm": "rx", + "n": 2, + "checks": 3, + "tests": 1, + "cyclesToGreen": 4, + "checkOk": true, + "built": true, + "visible": "22/22", + "hidden": "58/58", + "overlap": "26/26", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "labels-rx-3", + "task": "labels", + "arm": "rx", + "n": 3, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "22/22", + "hidden": "58/58", + "overlap": "26/26", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "labels-rx-4", + "task": "labels", + "arm": "rx", + "n": 4, + "checks": 2, + "tests": 1, + "cyclesToGreen": 3, + "checkOk": true, + "built": true, + "visible": "22/22", + "hidden": "58/58", + "overlap": "26/26", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "labels-rx-5", + "task": "labels", + "arm": "rx", + "n": 5, + "checks": 2, + "tests": 3, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "22/22", + "hidden": "58/58", + "overlap": "26/26", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "labels-rx-6", + "task": "labels", + "arm": "rx", + "n": 6, + "checks": 2, + "tests": 2, + "cyclesToGreen": 3, + "checkOk": true, + "built": true, + "visible": "22/22", + "hidden": "58/58", + "overlap": "26/26", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "related-rx-1", + "task": "related", + "arm": "rx", + "n": 1, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [ + { + "name": "base: retry after not found shows the issue", + "error": "expected [detail] to show \"stack trace attached\", it shows \"detail error: issue #3 not foundretryassign to guestrenamecloseloading commentspost\"" + } + ], + "failedVisible": [] + }, + { + "run": "related-rx-2", + "task": "related", + "arm": "rx", + "n": 2, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [ + { + "name": "base: retry after not found shows the issue", + "error": "expected [detail] to show \"stack trace attached\", it shows \"detail error: issue #3 not foundretryassign to guestrenamecloseloading commentspost\"" + } + ], + "failedVisible": [] + }, + { + "run": "related-rx-3", + "task": "related", + "arm": "rx", + "n": 3, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [ + { + "name": "base: retry after not found shows the issue", + "error": "expected [detail] to show \"stack trace attached\", it shows \"detail error: issue #3 not foundretryassign to guestrenamecloseloading commentspost\"" + } + ], + "failedVisible": [] + }, + { + "run": "related-rx-4", + "task": "related", + "arm": "rx", + "n": 4, + "checks": 2, + "tests": 1, + "cyclesToGreen": 3, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [ + { + "name": "base: retry after not found shows the issue", + "error": "expected [detail] to show \"stack trace attached\", it shows \"detail error: issue #3 not foundretryassign to guestrenamecloseloading commentspost\"" + } + ], + "failedVisible": [] + }, + { + "run": "related-rx-5", + "task": "related", + "arm": "rx", + "n": 5, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [ + { + "name": "base: retry after not found shows the issue", + "error": "expected [detail] to show \"stack trace attached\", it shows \"detail error: issue #3 not foundretryassign to guestrenamecloseloading commentspost\"" + } + ], + "failedVisible": [] + }, + { + "run": "related-rx-6", + "task": "related", + "arm": "rx", + "n": 6, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [ + { + "name": "base: retry after not found shows the issue", + "error": "expected [detail] to show \"stack trace attached\", it shows \"detail error: issue #3 not foundretryassign to guestrenamecloseloading commentspost\"" + } + ], + "failedVisible": [] + } +] diff --git a/eval/results/cost-2026-10-29/5b/secondary.json b/eval/results/cost-2026-10-29/5b/secondary.json new file mode 100644 index 0000000..6f74b44 --- /dev/null +++ b/eval/results/cost-2026-10-29/5b/secondary.json @@ -0,0 +1,290 @@ +[ + { + "run": "labels-rx-1", + "task": "labels", + "arm": "rx", + "billed": 349852.35, + "calls": 25, + "output": 17986, + "fixed": 36412, + "checks": 9, + "tests": 10, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 491, + "usd": 1.74926175 + }, + { + "run": "labels-rx-2", + "task": "labels", + "arm": "rx", + "billed": 145177.55, + "calls": 10, + "output": 4145, + "fixed": 36412, + "checks": 3, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 121, + "usd": 0.72588775 + }, + { + "run": "labels-rx-3", + "task": "labels", + "arm": "rx", + "billed": 151737.8, + "calls": 10, + "output": 4967, + "fixed": 36412, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 123, + "usd": 0.7586890000000001 + }, + { + "run": "labels-rx-4", + "task": "labels", + "arm": "rx", + "billed": 150597.84999999998, + "calls": 10, + "output": 4114, + "fixed": 36412, + "checks": 2, + "tests": 1, + "reads": 0, + "readsDocs": 0, + "mcp": 0, + "secs": 119, + "usd": 0.7529892499999999 + }, + { + "run": "labels-rx-5", + "task": "labels", + "arm": "rx", + "billed": 204742.30000000002, + "calls": 15, + "output": 7139, + "fixed": 36412, + "checks": 2, + "tests": 3, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 206, + "usd": 1.0237115 + }, + { + "run": "labels-rx-6", + "task": "labels", + "arm": "rx", + "billed": 188888.7, + "calls": 13, + "output": 6854, + "fixed": 36412, + "checks": 2, + "tests": 3, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 206, + "usd": 0.9444435000000001 + }, + { + "run": "related-rx-1", + "task": "related", + "arm": "rx", + "billed": 112109, + "calls": 7, + "output": 2478, + "fixed": 36411, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 93, + "usd": 0.5605450000000001 + }, + { + "run": "related-rx-2", + "task": "related", + "arm": "rx", + "billed": 115233.15000000001, + "calls": 7, + "output": 2586, + "fixed": 36411, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 93, + "usd": 0.5761657500000001 + }, + { + "run": "related-rx-3", + "task": "related", + "arm": "rx", + "billed": 122836.7, + "calls": 7, + "output": 3034, + "fixed": 36411, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 103, + "usd": 0.6141835 + }, + { + "run": "related-rx-4", + "task": "related", + "arm": "rx", + "billed": 132453.09999999998, + "calls": 9, + "output": 3475, + "fixed": 36411, + "checks": 2, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 114, + "usd": 0.6622655 + }, + { + "run": "related-rx-5", + "task": "related", + "arm": "rx", + "billed": 126773.25, + "calls": 8, + "output": 3405, + "fixed": 36411, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 103, + "usd": 0.6338662500000001 + }, + { + "run": "related-rx-6", + "task": "related", + "arm": "rx", + "billed": 119199.5, + "calls": 7, + "output": 2760, + "fixed": 36411, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 99, + "usd": 0.5959975000000001 + }, + { + "run": "count-rx-1", + "task": "count", + "arm": "rx", + "billed": 107107.54999999999, + "calls": 6, + "output": 2166, + "fixed": 36411, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 85, + "usd": 0.53553775 + }, + { + "run": "count-rx-2", + "task": "count", + "arm": "rx", + "billed": 108113.15, + "calls": 6, + "output": 2483, + "fixed": 36411, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 95, + "usd": 0.5405657500000001 + }, + { + "run": "count-rx-3", + "task": "count", + "arm": "rx", + "billed": 151665.7, + "calls": 11, + "output": 4071, + "fixed": 36411, + "checks": 1, + "tests": 3, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 166, + "usd": 0.7583285000000001 + }, + { + "run": "count-rx-4", + "task": "count", + "arm": "rx", + "billed": 109396.74999999999, + "calls": 6, + "output": 2109, + "fixed": 36411, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 80, + "usd": 0.54698375 + }, + { + "run": "count-rx-5", + "task": "count", + "arm": "rx", + "billed": 125104.04999999999, + "calls": 7, + "output": 3157, + "fixed": 36411, + "checks": 1, + "tests": 2, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 128, + "usd": 0.62552025 + }, + { + "run": "count-rx-6", + "task": "count", + "arm": "rx", + "billed": 111116.19999999998, + "calls": 6, + "output": 2453, + "fixed": 36411, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 90, + "usd": 0.555581 + } +] diff --git a/eval/results/cost-2026-10-29/5b/secondary.txt b/eval/results/cost-2026-10-29/5b/secondary.txt new file mode 100644 index 0000000..25040a3 --- /dev/null +++ b/eval/results/cost-2026-10-29/5b/secondary.txt @@ -0,0 +1,5 @@ +group n mean median calls out fixed checks tests reads docs mcp secs usd +ALL/rx 18 146228 125939 9.4 4410 36411 1.8 1.9 0.9 0.0 0 109 0.73 +count/rx 6 118751 110256 7.0 2740 36411 1.0 1.5 1.0 0.0 0 93 0.59 +labels/rx 6 198499 170313 13.8 7534 36412 3.2 3.2 0.8 0.0 0 165 0.99 +related/rx 6 121434 121018 7.5 2956 36411 1.2 1.0 1.0 0.0 0 101 0.61 diff --git a/eval/results/cost-2026-10-29/6/README.md b/eval/results/cost-2026-10-29/6/README.md new file mode 100644 index 0000000..86b7e0b --- /dev/null +++ b/eval/results/cost-2026-10-29/6/README.md @@ -0,0 +1,61 @@ +# Round 6 results: Foldkit + +Pre-registration: `eval/PREREG-cost-6.md` (5db7809). 18 Foldkit 0.170 runs on 2026-10-29, the same three changes to the +issue desk as rounds 5 and 5b. Baselines: round 5's React and Solid runs, 5b's .rx runs. + +## Primary: billed cost without the first call (H6a supported) + +| Arm | n | Mean | labels | related | count | +| --- | --- | --- | --- | --- | --- | +| React (5) | 18 | 76,289 | 77,925 | 82,286 | 68,656 | +| .rx (5b) | 18 | 100,342 | 152,579 | 75,566 | 72,882 | +| Solid 2 (5) | 18 | 110,031 | 124,784 | 93,853 | 111,457 | +| Foldkit (6) | 18 | 124,770 | 130,410 | 138,847 | 105,052 | + +(`cost-warm.json`, `harness/cost-warm.mjs`; one-sided permutation tests, 100,000 relabellings, seed 5.) + +- **H6a** (Foldkit costs more than React): Foldkit − React = 48,481, p < 0.0001. Supported: 64% more. +- **H6b** (Foldkit costs more than .rx 5b): Foldkit − .rx = 24,427, p = 0.048. Supported, narrowly. +- Foldkit − Solid = 14,739, p = 0.029 (descriptive). + +Raw billed cost (all 18 runs started with a cold cache, as 5b's did): mean 168,963, median 175,624, about $0.84 per +run (`cost-raw.txt`). + +## Where the cost goes + +- **Not turns.** Foldkit runs took 8.3 model calls, fewer than .rx (9.4) or Solid (10.3), close to React (7.1). Its + `./check` + `./test` calls: 2.4 per run, the fewest after React. +- **Output and context.** Foldkit runs wrote the most (6.5k output tokens per run; React 4.7k, .rx 4.4k), and every + call carries a bigger app: the Foldkit desk is 42 KB of source against React's 20 KB and .rx's 11 KB, read in full + and then re-read from cache on each call. +- **Little doc lookup.** Runs that opened `docs/foldkit` or the packages: labels 1 of 6, count 2 of 6, related 6 of 6 + (all looking up `OutMessage` and the `Update` helpers, to pass the hit from the issues page to the help page). +- Foldkit agents reported almost no uncertainty about framework timing; their doubts were about the brief. + +## Defects (`score.json`) + +2 of 18 Foldkit runs shipped a defect, both labels: "labels that load while an add is out keep the add" (an add made +while the issue's labels are still loading is lost when they land, or adding is blocked until they load). React, +Solid and .rx: 0 of 18 each. Fisher's exact test, Foldkit vs React: p = 0.49 (two-sided). Foldkit's references pass +the #4425 test; no Foldkit run failed it. + +## Reading + +Foldkit gives the agent explicit structure and no reactive timing to look up, and its agents did take few turns. It +still cost the most of the four, because each change is more code to write and every call carries a larger app. The +cost of a change follows two things: how sure the agent is of the semantics (turns) and how much code the change and +the app need (tokens per turn). .rx with the effects guide is short on both for related and count; Foldkit is low on +turns and high on tokens; React is low on both. + +## Limits + +- The Foldkit bases were written for this round by someone new to Foldkit. One base flaw showed up in agents' + reports: `retry` on the detail also discards a comments answer still in flight (comments share the detail's + request number), so `loading comments` can stick. No test covers it; three agents fixed it in passing. +- No lint in the Foldkit arm (its oxlint plugin is not on npm). +- Not concurrent with the baselines; compared on the cache-neutral measure. + +## Files + +`agents.txt`, `agents-all.txt` (React 5, Solid 5, .rx 5b, Foldkit 6), `cost-raw.txt`, `cost-warm.json`, +`secondary.txt`/`.json`, `score.json`. diff --git a/eval/results/cost-2026-10-29/6/agents-all.txt b/eval/results/cost-2026-10-29/6/agents-all.txt new file mode 100644 index 0000000..7147094 --- /dev/null +++ b/eval/results/cost-2026-10-29/6/agents-all.txt @@ -0,0 +1,72 @@ +labels-react-1 ac3a12f11d67138b9 +labels-react-2 a7530be4b9aba22fc +labels-react-3 a3ce41aaeae877bca +labels-react-4 abcd5e83e0692da1e +labels-react-5 a3e1cac1749a176a0 +labels-react-6 ad6d11b4cb3fb268e +labels-solid-1 ac1e321ec1e630517 +labels-solid-2 a6ad4db025d03e187 +labels-solid-3 a9a6bddbc84e35f31 +labels-solid-4 afde50adee0e74781 +labels-solid-5 a380a912d0be8a2d0 +labels-solid-6 a57b0c8ae11bb907f +related-react-1 ad66eb3536186dbeb +related-react-2 a495310d65ae60350 +related-react-3 a79dc1d0ac5ef11b4 +related-react-4 a88dcf662aaee2bfb +related-react-5 a4a31a6bbb321800c +related-react-6 a7bb5fb37ce4305d4 +related-solid-1 a73a99f399d904f89 +related-solid-2 ad59891b48506129d +related-solid-3 a0b38f433b04e6d49 +related-solid-4 a90e191e42a946e05 +related-solid-5 a9cab206dc3a87df3 +related-solid-6 a22e55fd95b02aad8 +count-react-1 a8aaf706b924539a2 +count-react-2 a7d205c78ca6fe9f2 +count-react-3 a62abd93538ff02b5 +count-react-4 a6793ee30041cc77f +count-react-5 a384fc841447f85c5 +count-react-6 a3d6e1d10f6af1beb +count-solid-1 a86c370c8f7db960a +count-solid-2 a84a70a1ea36d685b +count-solid-3 ae5dd4435679539c1 +count-solid-4 a1c57189c44fc451a +count-solid-5 a8a8bfaecaaa23e8a +count-solid-6 a8767c8ce1e7ad771 +labels-rx5b-1 a84819d05d26c5a5d +labels-rx5b-2 a37a4a9b78fcf3c7e +labels-rx5b-3 a5bbe0734ef332b70 +labels-rx5b-4 a50c93e7b9807fff0 +labels-rx5b-5 a2a584cdc870dd654 +labels-rx5b-6 a7e06a6ea16068bae +related-rx5b-1 a92b2b3db799c730d +related-rx5b-2 a1600718039ec7683 +related-rx5b-3 af43c33da884a608e +related-rx5b-4 a31185ae33d4acbd1 +related-rx5b-5 aed81440332f8b402 +related-rx5b-6 ac06b77d7a5190c02 +count-rx5b-1 a2f1075a896060f42 +count-rx5b-2 a6da123728f99531a +count-rx5b-3 ae5dd52e3f7831bbd +count-rx5b-4 a60347551dc3de720 +count-rx5b-5 a670401a331df3b08 +count-rx5b-6 a80329d7f9f3d2e76 +labels-foldkit-1 a0da23ca109809d8b +labels-foldkit-2 a0b81b7263abc79c5 +labels-foldkit-3 acf039f11f6976211 +labels-foldkit-4 a8ad3603f78060750 +labels-foldkit-5 ad5bc6affeae15292 +labels-foldkit-6 a7457fa71281ae20c +related-foldkit-1 a356beba4b28eeebe +related-foldkit-2 ab9ae629cb7d25f40 +related-foldkit-3 a1b13b8278340f4f1 +related-foldkit-4 a7f73e8f8b1ba2040 +related-foldkit-5 a10f2153466c3c260 +related-foldkit-6 adeb8abd85999b7d4 +count-foldkit-1 af6766561ee912b5a +count-foldkit-2 a2edf04517e6e3135 +count-foldkit-3 a6fb3585f6b8814f0 +count-foldkit-4 a710706cc746d6a92 +count-foldkit-5 aa5fad9c03455efa4 +count-foldkit-6 ab7320eb0c3cdd2c9 diff --git a/eval/results/cost-2026-10-29/6/agents.txt b/eval/results/cost-2026-10-29/6/agents.txt new file mode 100644 index 0000000..41893a0 --- /dev/null +++ b/eval/results/cost-2026-10-29/6/agents.txt @@ -0,0 +1,18 @@ +labels-foldkit-1 a0da23ca109809d8b +labels-foldkit-2 a0b81b7263abc79c5 +labels-foldkit-3 acf039f11f6976211 +labels-foldkit-4 a8ad3603f78060750 +labels-foldkit-5 ad5bc6affeae15292 +labels-foldkit-6 a7457fa71281ae20c +related-foldkit-1 a356beba4b28eeebe +related-foldkit-2 ab9ae629cb7d25f40 +related-foldkit-3 a1b13b8278340f4f1 +related-foldkit-4 a7f73e8f8b1ba2040 +related-foldkit-5 a10f2153466c3c260 +related-foldkit-6 adeb8abd85999b7d4 +count-foldkit-1 af6766561ee912b5a +count-foldkit-2 a2edf04517e6e3135 +count-foldkit-3 a6fb3585f6b8814f0 +count-foldkit-4 a710706cc746d6a92 +count-foldkit-5 aa5fad9c03455efa4 +count-foldkit-6 ab7320eb0c3cdd2c9 diff --git a/eval/results/cost-2026-10-29/6/cost-raw.txt b/eval/results/cost-2026-10-29/6/cost-raw.txt new file mode 100644 index 0000000..9edf98e --- /dev/null +++ b/eval/results/cost-2026-10-29/6/cost-raw.txt @@ -0,0 +1,18 @@ +labels-foldkit-1 billed 164455 calls 7 out 7926 cacheRead 337907 cacheWrite 72816 +labels-foldkit-2 billed 173734 calls 8 out 7816 cacheRead 408728 cacheWrite 75012 +labels-foldkit-3 billed 177515 calls 9 out 7857 cacheRead 477859 cacheWrite 72341 +labels-foldkit-4 billed 178417 calls 9 out 6621 cacheRead 476253 cacheWrite 78135 +labels-foldkit-5 billed 177661 calls 8 out 8330 cacheRead 416679 cacheWrite 75462 +labels-foldkit-6 billed 186243 calls 8 out 9016 cacheRead 407732 cacheWrite 80299 +related-foldkit-1 billed 151609 calls 9 out 6562 cacheRead 529949 cacheWrite 52629 +related-foldkit-2 billed 185072 calls 9 out 6830 cacheRead 508198 cacheWrite 80067 +related-foldkit-3 billed 168776 calls 8 out 5860 cacheRead 421110 cacheWrite 77879 +related-foldkit-4 billed 190635 calls 9 out 7312 cacheRead 510648 cacheWrite 82394 +related-foldkit-5 billed 192998 calls 9 out 7589 cacheRead 512776 cacheWrite 83006 +related-foldkit-6 billed 188109 calls 9 out 7135 cacheRead 515922 cacheWrite 80659 +count-foldkit-1 billed 140391 calls 7 out 4302 cacheRead 330915 cacheWrite 68620 +count-foldkit-2 billed 137768 calls 7 out 3293 cacheRead 336206 cacheWrite 70135 +count-foldkit-3 billed 135499 calls 7 out 3252 cacheRead 331542 cacheWrite 68857 +count-foldkit-4 billed 147355 calls 8 out 3934 cacheRead 400816 cacheWrite 70070 +count-foldkit-5 billed 136782 calls 7 out 3681 cacheRead 330489 cacheWrite 68251 +count-foldkit-6 billed 208315 calls 11 out 10150 cacheRead 619966 cacheWrite 76437 diff --git a/eval/results/cost-2026-10-29/6/cost-warm.json b/eval/results/cost-2026-10-29/6/cost-warm.json new file mode 100644 index 0000000..6ddb77e --- /dev/null +++ b/eval/results/cost-2026-10-29/6/cost-warm.json @@ -0,0 +1,546 @@ +{ + "runs": [ + { + "run": "labels-react-1", + "task": "labels", + "arm": "react", + "first": 46123.25, + "rest": 87825.85 + }, + { + "run": "labels-react-2", + "task": "labels", + "arm": "react", + "first": 45858.25, + "rest": 67807.05 + }, + { + "run": "labels-react-3", + "task": "labels", + "arm": "react", + "first": 45858.25, + "rest": 78040.25 + }, + { + "run": "labels-react-4", + "task": "labels", + "arm": "react", + "first": 45858.25, + "rest": 77020.20000000001 + }, + { + "run": "labels-react-5", + "task": "labels", + "arm": "react", + "first": 45858.25, + "rest": 76917.85 + }, + { + "run": "labels-react-6", + "task": "labels", + "arm": "react", + "first": 45858.25, + "rest": 79938.85000000002 + }, + { + "run": "labels-solid-1", + "task": "labels", + "arm": "solid", + "first": 45864.5, + "rest": 79828.5 + }, + { + "run": "labels-solid-2", + "task": "labels", + "arm": "solid", + "first": 45864.5, + "rest": 145814 + }, + { + "run": "labels-solid-3", + "task": "labels", + "arm": "solid", + "first": 45864.5, + "rest": 128603.95000000001 + }, + { + "run": "labels-solid-4", + "task": "labels", + "arm": "solid", + "first": 45864.5, + "rest": 108886.35 + }, + { + "run": "labels-solid-5", + "task": "labels", + "arm": "solid", + "first": 45864.5, + "rest": 137785.15 + }, + { + "run": "labels-solid-6", + "task": "labels", + "arm": "solid", + "first": 45864.5, + "rest": 147785.7 + }, + { + "run": "related-react-1", + "task": "related", + "arm": "react", + "first": 14444.35, + "rest": 90833.90000000001 + }, + { + "run": "related-react-2", + "task": "related", + "arm": "react", + "first": 14444.35, + "rest": 83632.6 + }, + { + "run": "related-react-3", + "task": "related", + "arm": "react", + "first": 14444.35, + "rest": 75966.84999999999 + }, + { + "run": "related-react-4", + "task": "related", + "arm": "react", + "first": 14444.35, + "rest": 82079.25 + }, + { + "run": "related-react-5", + "task": "related", + "arm": "react", + "first": 14444.35, + "rest": 66299 + }, + { + "run": "related-react-6", + "task": "related", + "arm": "react", + "first": 14444.35, + "rest": 94903.3 + }, + { + "run": "related-solid-1", + "task": "related", + "arm": "solid", + "first": 14450.6, + "rest": 78462.35 + }, + { + "run": "related-solid-2", + "task": "related", + "arm": "solid", + "first": 14450.6, + "rest": 111899.5 + }, + { + "run": "related-solid-3", + "task": "related", + "arm": "solid", + "first": 14450.6, + "rest": 81628.45 + }, + { + "run": "related-solid-4", + "task": "related", + "arm": "solid", + "first": 14450.6, + "rest": 85238.65000000001 + }, + { + "run": "related-solid-5", + "task": "related", + "arm": "solid", + "first": 14450.6, + "rest": 123229.2 + }, + { + "run": "related-solid-6", + "task": "related", + "arm": "solid", + "first": 14450.6, + "rest": 82657.9 + }, + { + "run": "count-react-1", + "task": "count", + "arm": "react", + "first": 14444.35, + "rest": 58194.55 + }, + { + "run": "count-react-2", + "task": "count", + "arm": "react", + "first": 14444.35, + "rest": 58718.3 + }, + { + "run": "count-react-3", + "task": "count", + "arm": "react", + "first": 14444.35, + "rest": 53021.2 + }, + { + "run": "count-react-4", + "task": "count", + "arm": "react", + "first": 14444.35, + "rest": 114939.55000000002 + }, + { + "run": "count-react-5", + "task": "count", + "arm": "react", + "first": 14444.35, + "rest": 64708.8 + }, + { + "run": "count-react-6", + "task": "count", + "arm": "react", + "first": 14444.35, + "rest": 62355.9 + }, + { + "run": "count-solid-1", + "task": "count", + "arm": "solid", + "first": 14450.6, + "rest": 95240.90000000001 + }, + { + "run": "count-solid-2", + "task": "count", + "arm": "solid", + "first": 14450.6, + "rest": 147433.35 + }, + { + "run": "count-solid-3", + "task": "count", + "arm": "solid", + "first": 14450.6, + "rest": 111489.35 + }, + { + "run": "count-solid-4", + "task": "count", + "arm": "solid", + "first": 14450.6, + "rest": 97693.15 + }, + { + "run": "count-solid-5", + "task": "count", + "arm": "solid", + "first": 14450.6, + "rest": 104428.74999999999 + }, + { + "run": "count-solid-6", + "task": "count", + "arm": "solid", + "first": 14450.6, + "rest": 112458.79999999999 + }, + { + "run": "labels-rx5b-1", + "task": "labels", + "arm": "rx5b", + "first": 45874.5, + "rest": 303977.85000000003 + }, + { + "run": "labels-rx5b-2", + "task": "labels", + "arm": "rx5b", + "first": 45874.5, + "rest": 99303.05 + }, + { + "run": "labels-rx5b-3", + "task": "labels", + "arm": "rx5b", + "first": 45874.5, + "rest": 105863.29999999999 + }, + { + "run": "labels-rx5b-4", + "task": "labels", + "arm": "rx5b", + "first": 46149.5, + "rest": 104448.34999999999 + }, + { + "run": "labels-rx5b-5", + "task": "labels", + "arm": "rx5b", + "first": 45874.5, + "rest": 158867.80000000002 + }, + { + "run": "labels-rx5b-6", + "task": "labels", + "arm": "rx5b", + "first": 45874.5, + "rest": 143014.19999999998 + }, + { + "run": "related-rx5b-1", + "task": "related", + "arm": "rx5b", + "first": 45868.25, + "rest": 66240.75 + }, + { + "run": "related-rx5b-2", + "task": "related", + "arm": "rx5b", + "first": 45868.25, + "rest": 69364.9 + }, + { + "run": "related-rx5b-3", + "task": "related", + "arm": "rx5b", + "first": 45868.25, + "rest": 76968.45000000001 + }, + { + "run": "related-rx5b-4", + "task": "related", + "arm": "rx5b", + "first": 45868.25, + "rest": 86584.84999999999 + }, + { + "run": "related-rx5b-5", + "task": "related", + "arm": "rx5b", + "first": 45868.25, + "rest": 80905 + }, + { + "run": "related-rx5b-6", + "task": "related", + "arm": "rx5b", + "first": 45868.25, + "rest": 73331.25 + }, + { + "run": "count-rx5b-1", + "task": "count", + "arm": "rx5b", + "first": 45868.25, + "rest": 61239.3 + }, + { + "run": "count-rx5b-2", + "task": "count", + "arm": "rx5b", + "first": 45868.25, + "rest": 62244.9 + }, + { + "run": "count-rx5b-3", + "task": "count", + "arm": "rx5b", + "first": 45868.25, + "rest": 105797.45000000001 + }, + { + "run": "count-rx5b-4", + "task": "count", + "arm": "rx5b", + "first": 45868.25, + "rest": 63528.50000000001 + }, + { + "run": "count-rx5b-5", + "task": "count", + "arm": "rx5b", + "first": 45868.25, + "rest": 79235.8 + }, + { + "run": "count-rx5b-6", + "task": "count", + "arm": "rx5b", + "first": 45868.25, + "rest": 65247.950000000004 + }, + { + "run": "labels-foldkit-1", + "task": "labels", + "arm": "foldkit", + "first": 45880.75, + "rest": 118573.95 + }, + { + "run": "labels-foldkit-2", + "task": "labels", + "arm": "foldkit", + "first": 45880.75, + "rest": 127853.04999999999 + }, + { + "run": "labels-foldkit-3", + "task": "labels", + "arm": "foldkit", + "first": 45880.75, + "rest": 131634.40000000002 + }, + { + "run": "labels-foldkit-4", + "task": "labels", + "arm": "foldkit", + "first": 45880.75, + "rest": 132536.3 + }, + { + "run": "labels-foldkit-5", + "task": "labels", + "arm": "foldkit", + "first": 45880.75, + "rest": 131780.65000000002 + }, + { + "run": "labels-foldkit-6", + "task": "labels", + "arm": "foldkit", + "first": 46160.75, + "rest": 140082.2 + }, + { + "run": "related-foldkit-1", + "task": "related", + "arm": "foldkit", + "first": 14466.85, + "rest": 137142.3 + }, + { + "run": "related-foldkit-2", + "task": "related", + "arm": "foldkit", + "first": 45874.5, + "rest": 139197.05 + }, + { + "run": "related-foldkit-3", + "task": "related", + "arm": "foldkit", + "first": 45874.5, + "rest": 122901.25 + }, + { + "run": "related-foldkit-4", + "task": "related", + "arm": "foldkit", + "first": 45874.5, + "rest": 144760.80000000002 + }, + { + "run": "related-foldkit-5", + "task": "related", + "arm": "foldkit", + "first": 45874.5, + "rest": 147123.6 + }, + { + "run": "related-foldkit-6", + "task": "related", + "arm": "foldkit", + "first": 46149.5, + "rest": 141959.45 + }, + { + "run": "count-foldkit-1", + "task": "count", + "arm": "foldkit", + "first": 46149.5, + "rest": 94240.99999999999 + }, + { + "run": "count-foldkit-2", + "task": "count", + "arm": "foldkit", + "first": 45874.5, + "rest": 91893.85 + }, + { + "run": "count-foldkit-3", + "task": "count", + "arm": "foldkit", + "first": 45874.5, + "rest": 89624.95 + }, + { + "run": "count-foldkit-4", + "task": "count", + "arm": "foldkit", + "first": 45874.5, + "rest": 101480.6 + }, + { + "run": "count-foldkit-5", + "task": "count", + "arm": "foldkit", + "first": 46149.5, + "rest": 90632.15 + }, + { + "run": "count-foldkit-6", + "task": "count", + "arm": "foldkit", + "first": 45874.5, + "rest": 162440.35 + } + ], + "tests": [ + { + "higher": "solid", + "lower": "react", + "diff": 33742.26388888889, + "p": 0.00000999990000099999 + }, + { + "higher": "solid", + "lower": "rx5b", + "diff": 9688.90833333334, + "p": 0.3030569694303057 + }, + { + "higher": "rx5b", + "lower": "react", + "diff": 24053.35555555555, + "p": 0.02754972450275497 + }, + { + "higher": "foldkit", + "lower": "react", + "diff": 48480.81388888892, + "p": 0.00000999990000099999 + }, + { + "higher": "foldkit", + "lower": "solid", + "diff": 14738.550000000032, + "p": 0.02943970560294397 + }, + { + "higher": "foldkit", + "lower": "rx5b", + "diff": 24427.458333333372, + "p": 0.047919520804791955 + } + ] +} diff --git a/eval/results/cost-2026-10-29/6/score.json b/eval/results/cost-2026-10-29/6/score.json new file mode 100644 index 0000000..cb392b9 --- /dev/null +++ b/eval/results/cost-2026-10-29/6/score.json @@ -0,0 +1,354 @@ +[ + { + "run": "count-foldkit-1", + "task": "count", + "arm": "foldkit", + "n": 1, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "count-foldkit-2", + "task": "count", + "arm": "foldkit", + "n": 2, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "count-foldkit-3", + "task": "count", + "arm": "foldkit", + "n": 3, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "count-foldkit-4", + "task": "count", + "arm": "foldkit", + "n": 4, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "count-foldkit-5", + "task": "count", + "arm": "foldkit", + "n": 5, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "count-foldkit-6", + "task": "count", + "arm": "foldkit", + "n": 6, + "checks": 2, + "tests": 1, + "cyclesToGreen": 3, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "labels-foldkit-1", + "task": "labels", + "arm": "foldkit", + "n": 1, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "22/22", + "hidden": "58/58", + "overlap": "26/26", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "labels-foldkit-2", + "task": "labels", + "arm": "foldkit", + "n": 2, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "22/22", + "hidden": "58/58", + "overlap": "25/26", + "failedOverlap": [ + { + "name": "labels that load while an add is out keep the add", + "error": "expected [label] rows [\"bug\",\"ui\",\"crash\"], got [\"bug\",\"ui\"]" + } + ], + "shippedDefect": true, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "labels-foldkit-3", + "task": "labels", + "arm": "foldkit", + "n": 3, + "checks": 2, + "tests": 2, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "22/22", + "hidden": "58/58", + "overlap": "26/26", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "labels-foldkit-4", + "task": "labels", + "arm": "foldkit", + "n": 4, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "22/22", + "hidden": "58/58", + "overlap": "26/26", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "labels-foldkit-5", + "task": "labels", + "arm": "foldkit", + "n": 5, + "checks": 2, + "tests": 2, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "22/22", + "hidden": "58/58", + "overlap": "25/26", + "failedOverlap": [ + { + "name": "labels that load while an add is out keep the add", + "error": "expected [label] rows [\"bug\",\"ui\",\"crash\"], got [\"bug\",\"ui\"]" + } + ], + "shippedDefect": true, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "labels-foldkit-6", + "task": "labels", + "arm": "foldkit", + "n": 6, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "22/22", + "hidden": "58/58", + "overlap": "26/26", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "related-foldkit-1", + "task": "related", + "arm": "foldkit", + "n": 1, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "related-foldkit-2", + "task": "related", + "arm": "foldkit", + "n": 2, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "related-foldkit-3", + "task": "related", + "arm": "foldkit", + "n": 3, + "checks": 2, + "tests": 1, + "cyclesToGreen": 3, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "related-foldkit-4", + "task": "related", + "arm": "foldkit", + "n": 4, + "checks": 2, + "tests": 1, + "cyclesToGreen": 3, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "related-foldkit-5", + "task": "related", + "arm": "foldkit", + "n": 5, + "checks": 2, + "tests": 1, + "cyclesToGreen": 3, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "related-foldkit-6", + "task": "related", + "arm": "foldkit", + "n": 6, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + } +] diff --git a/eval/results/cost-2026-10-29/6/secondary.json b/eval/results/cost-2026-10-29/6/secondary.json new file mode 100644 index 0000000..8db073b --- /dev/null +++ b/eval/results/cost-2026-10-29/6/secondary.json @@ -0,0 +1,290 @@ +[ + { + "run": "labels-foldkit-1", + "task": "labels", + "arm": "foldkit", + "billed": 164454.69999999998, + "calls": 7, + "output": 7926, + "fixed": 36413, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 192, + "usd": 0.8222735 + }, + { + "run": "labels-foldkit-2", + "task": "labels", + "arm": "foldkit", + "billed": 173733.80000000002, + "calls": 8, + "output": 7816, + "fixed": 36413, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 192, + "usd": 0.8686690000000001 + }, + { + "run": "labels-foldkit-3", + "task": "labels", + "arm": "foldkit", + "billed": 177515.15000000005, + "calls": 9, + "output": 7857, + "fixed": 36413, + "checks": 2, + "tests": 2, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 233, + "usd": 0.8875757500000003 + }, + { + "run": "labels-foldkit-4", + "task": "labels", + "arm": "foldkit", + "billed": 178417.05000000005, + "calls": 9, + "output": 6621, + "fixed": 36413, + "checks": 1, + "tests": 1, + "reads": 2, + "readsDocs": 0, + "mcp": 0, + "secs": 177, + "usd": 0.8920852500000003 + }, + { + "run": "labels-foldkit-5", + "task": "labels", + "arm": "foldkit", + "billed": 177661.40000000002, + "calls": 8, + "output": 8330, + "fixed": 36413, + "checks": 2, + "tests": 2, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 233, + "usd": 0.8883070000000002 + }, + { + "run": "labels-foldkit-6", + "task": "labels", + "arm": "foldkit", + "billed": 186242.95, + "calls": 8, + "output": 9016, + "fixed": 36413, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 198, + "usd": 0.9312147500000001 + }, + { + "run": "related-foldkit-1", + "task": "related", + "arm": "foldkit", + "billed": 151609.15, + "calls": 9, + "output": 6562, + "fixed": 36412, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 181, + "usd": 0.7580457500000001 + }, + { + "run": "related-foldkit-2", + "task": "related", + "arm": "foldkit", + "billed": 185071.55, + "calls": 9, + "output": 6830, + "fixed": 36412, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 184, + "usd": 0.92535775 + }, + { + "run": "related-foldkit-3", + "task": "related", + "arm": "foldkit", + "billed": 168775.75, + "calls": 8, + "output": 5860, + "fixed": 36412, + "checks": 2, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 185, + "usd": 0.8438787500000001 + }, + { + "run": "related-foldkit-4", + "task": "related", + "arm": "foldkit", + "billed": 190635.30000000002, + "calls": 9, + "output": 7312, + "fixed": 36412, + "checks": 2, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 208, + "usd": 0.9531765000000002 + }, + { + "run": "related-foldkit-5", + "task": "related", + "arm": "foldkit", + "billed": 192998.1, + "calls": 9, + "output": 7589, + "fixed": 36412, + "checks": 2, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 210, + "usd": 0.9649905000000001 + }, + { + "run": "related-foldkit-6", + "task": "related", + "arm": "foldkit", + "billed": 188108.94999999998, + "calls": 9, + "output": 7135, + "fixed": 36412, + "checks": 1, + "tests": 1, + "reads": 0, + "readsDocs": 0, + "mcp": 0, + "secs": 184, + "usd": 0.94054475 + }, + { + "run": "count-foldkit-1", + "task": "count", + "arm": "foldkit", + "billed": 140390.5, + "calls": 7, + "output": 4302, + "fixed": 36412, + "checks": 1, + "tests": 1, + "reads": 0, + "readsDocs": 0, + "mcp": 0, + "secs": 121, + "usd": 0.7019525000000001 + }, + { + "run": "count-foldkit-2", + "task": "count", + "arm": "foldkit", + "billed": 137768.34999999998, + "calls": 7, + "output": 3293, + "fixed": 36412, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 103, + "usd": 0.6888417499999999 + }, + { + "run": "count-foldkit-3", + "task": "count", + "arm": "foldkit", + "billed": 135499.45, + "calls": 7, + "output": 3252, + "fixed": 36412, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 99, + "usd": 0.6774972500000002 + }, + { + "run": "count-foldkit-4", + "task": "count", + "arm": "foldkit", + "billed": 147355.09999999998, + "calls": 8, + "output": 3934, + "fixed": 36412, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 130, + "usd": 0.7367754999999999 + }, + { + "run": "count-foldkit-5", + "task": "count", + "arm": "foldkit", + "billed": 136781.65, + "calls": 7, + "output": 3681, + "fixed": 36412, + "checks": 1, + "tests": 1, + "reads": 0, + "readsDocs": 0, + "mcp": 0, + "secs": 104, + "usd": 0.68390825 + }, + { + "run": "count-foldkit-6", + "task": "count", + "arm": "foldkit", + "billed": 208314.84999999998, + "calls": 11, + "output": 10150, + "fixed": 36412, + "checks": 2, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 219, + "usd": 1.04157425 + } +] diff --git a/eval/results/cost-2026-10-29/6/secondary.txt b/eval/results/cost-2026-10-29/6/secondary.txt new file mode 100644 index 0000000..50e6f78 --- /dev/null +++ b/eval/results/cost-2026-10-29/6/secondary.txt @@ -0,0 +1,5 @@ +group n mean median calls out fixed checks tests reads docs mcp secs usd +ALL/foldkit 18 168963 175624 8.3 6526 36412 1.3 1.1 0.9 0.0 0 185 0.84 +count/foldkit 6 151018 139079 7.8 4769 36412 1.2 1.0 0.7 0.0 0 113 0.76 +labels/foldkit 6 176338 177588 8.2 7928 36413 1.3 1.3 1.2 0.0 0 195 0.88 +related/foldkit 6 179533 186590 8.8 6881 36412 1.5 1.0 0.8 0.0 0 185 0.90 diff --git a/eval/results/cost-2026-10-29/README.md b/eval/results/cost-2026-10-29/README.md new file mode 100644 index 0000000..8019217 --- /dev/null +++ b/eval/results/cost-2026-10-29/README.md @@ -0,0 +1,77 @@ +# Round 5 results: what a change costs + +Pre-registration: `eval/PREREG-cost.md` (frozen at 782d83e, prompt start clarified at 5656a1f). 54 runs, 2026-10-29: +3 tasks (labels, related, count) × 3 arms (.rx, React, Solid 2 TSX) × 6, each changing the issue desk. Frontier model +at the agent harness's default effort (the id is in the session record, not in the repo). Toolchain: Solid next +5e68925c (2.0.0-rc.20). + +## Primary: billed cost (H1 not supported) + +Billed units = input × 1 + cache write × 1.25 + cache read × 0.1 + output × 5, per run (`cost.txt`, `cost.json`). + +| Arm | n | Mean | Median | sd | API $ / run | +| --- | --- | --- | --- | --- | --- | +| React | 18 | 101,219 | 101,678 | 22,425 | 0.51 | +| .rx | 18 | 131,212 | 119,450 | 53,926 | 0.66 | +| Solid 2 TSX | 18 | 134,953 | 126,145 | 33,779 | 0.67 | + +- **H1** (.rx cheaper than React): .rx cost 30% more (React − .rx = −29,992). One-sided permutation p = 0.989. + H1 does not hold; the difference runs the other way. +- **H2** (.rx cheaper than Solid): .rx 2.8% cheaper (Solid − .rx = 3,742), p = 0.42. No difference shown. +- React − Solid (descriptive): Solid cost 33% more than React, about what .rx did. + +## Per task (`secondary.txt`) + +| Task | React | .rx | Solid | calls R / rx / S | +| --- | --- | --- | --- | --- | +| labels | 123,827 | 158,986 | 170,648 | 6.8 / 10.3 / 11.3 | +| related | 96,730 | 99,658 | 108,303 | 8.0 / 8.7 / 8.7 | +| count | 83,101 | 134,991 | 125,908 | 6.3 / 10.2 / 10.8 | + +Means. One .rx run each in labels (labels-rx-6, 303k, 22 calls: it tried and backed out of reverting each in-flight +label on its own) and count (count-rx-1, 224k) wandered; without them .rx's median is still above React's. + +## Where the cost goes + +- **Not output**: output tokens per run are the same in React and .rx (4,658 vs 4,633; Solid 5,564). +- **Not the source**: the .rx base is the smallest (15 KB of source vs React's 24 to 28 KB), and every arm read it in + one or two calls. +- **Turns**: .rx and Solid runs took 9.7 and 10.3 model calls against React's 7.1. Each extra call re-reads the + whole context from cache, so turns are what cost. +- **What the extra turns were**: reading the language's semantics. 16 of 18 .rx runs went into `docs/` (grepping + `spec/` for how an effect behaves under a guess, 2.2 doc calls per run); 13 of 18 Solid runs read + `node_modules/solid-js` docs (1.0 per run); React runs read none. Several .rx and Solid runs also wrote and removed + their own extra tests to confirm a timing they could not get from the docs. React agents trusted what they know. +- **Inline docs**: the .rx prompt is 15 KB, React's 1.8 KB, Solid's 11 KB. About 3k extra tokens, carried through + every call: roughly 6 to 7k billed units per .rx run, a fifth of the gap. +- `./check` + `./test` calls: React 2.2, .rx 3.3, Solid 3.7 per run. +- Wall-clock (median seconds): React 158, .rx 116, Solid 175. + +## Secondary: defects (`score.json`) + +0 of 18 runs shipped a defect in each arm (visible, hidden and overlap tests at "done"). Nothing for Fisher's test to +compare; cost per defect-free run is the cost per run above. + +Alongside (outside the count, as pre-registered): "base: retry after not found shows the issue" fails in all 6 .rx and +all 6 Solid related runs and in no React run: solidjs/solid#4425, the runtime bug named in the pre-registration. No +other runtime bug showed during the round. + +## Checks + +- No run used a messaging tool (`mcp` column, 0 calls in all 54 transcripts). +- Every agent reported `./check` and the visible tests green at "done"; scoring agrees (21/21 or 22/22 everywhere). + +## Reading + +Changing an app the model already knows how to write (React) is cheaper than changing one in a framework it has to +look up, and .rx is in the second group with Solid 2: the agent's cost is set by how sure it is of the semantics, not +by how short the code is. .rx's smaller source did not show up in the bill at this app size (≈15 to 28 KB), because +reading source is a small, one-time part of a run. The pilot's 9% advantage did not replicate. + +## Files + +- `agents.txt`: run → transcript id. +- `cost.txt`, `cost.json`: primary measure per run, `harness/cost.mjs`. +- `secondary.txt`, `secondary.json`: per task and arm, `harness/cost-split.mjs`. +- `score.json`: defects per run, `harness/score.mjs --overlap` (run one run at a time, merged). +- The run directories are in `/home/claude/runs/r5` of the session that ran them, not committed (as in round 4). diff --git a/eval/results/cost-2026-10-29/agents.txt b/eval/results/cost-2026-10-29/agents.txt new file mode 100644 index 0000000..bc49fc9 --- /dev/null +++ b/eval/results/cost-2026-10-29/agents.txt @@ -0,0 +1,54 @@ +labels-rx-1 ab643b8e389628edd +labels-rx-2 a2a2814667ce707ef +labels-rx-3 a59f71b829356b749 +labels-rx-4 a9feb671f5da817e1 +labels-rx-5 a48313fd8bb50c698 +labels-rx-6 aa124395829f42087 +labels-react-1 ac3a12f11d67138b9 +labels-react-2 a7530be4b9aba22fc +labels-react-3 a3ce41aaeae877bca +labels-react-4 abcd5e83e0692da1e +labels-react-5 a3e1cac1749a176a0 +labels-react-6 ad6d11b4cb3fb268e +labels-solid-1 ac1e321ec1e630517 +labels-solid-2 a6ad4db025d03e187 +labels-solid-3 a9a6bddbc84e35f31 +labels-solid-4 afde50adee0e74781 +labels-solid-5 a380a912d0be8a2d0 +labels-solid-6 a57b0c8ae11bb907f +related-rx-1 ab9d0249acdd9618e +related-rx-2 ae977bbeaa3b25509 +related-rx-3 a28b9c005084f5088 +related-rx-4 abb3f2cdcd00631a3 +related-rx-5 af82774a607c9edf1 +related-rx-6 a6026926325a08bd4 +related-react-1 ad66eb3536186dbeb +related-react-2 a495310d65ae60350 +related-react-3 a79dc1d0ac5ef11b4 +related-react-4 a88dcf662aaee2bfb +related-react-5 a4a31a6bbb321800c +related-react-6 a7bb5fb37ce4305d4 +related-solid-1 a73a99f399d904f89 +related-solid-2 ad59891b48506129d +related-solid-3 a0b38f433b04e6d49 +related-solid-4 a90e191e42a946e05 +related-solid-5 a9cab206dc3a87df3 +related-solid-6 a22e55fd95b02aad8 +count-rx-1 a95c11f4e8a30aba3 +count-rx-2 a7d5c53afa421a857 +count-rx-3 a1de92203f2f1f27e +count-rx-4 a35fd01e61e20d441 +count-rx-5 ae3d82052f4e50de8 +count-rx-6 a2ab23ec7e542583d +count-react-1 a8aaf706b924539a2 +count-react-2 a7d205c78ca6fe9f2 +count-react-3 a62abd93538ff02b5 +count-react-4 a6793ee30041cc77f +count-react-5 a384fc841447f85c5 +count-react-6 a3d6e1d10f6af1beb +count-solid-1 a86c370c8f7db960a +count-solid-2 a84a70a1ea36d685b +count-solid-3 ae5dd4435679539c1 +count-solid-4 a1c57189c44fc451a +count-solid-5 a8a8bfaecaaa23e8a +count-solid-6 a8767c8ce1e7ad771 diff --git a/eval/results/cost-2026-10-29/cost.json b/eval/results/cost-2026-10-29/cost.json new file mode 100644 index 0000000..84377fb --- /dev/null +++ b/eval/results/cost-2026-10-29/cost.json @@ -0,0 +1,666 @@ +{ + "weights": { + "input": 1, + "cacheWrite": 1.25, + "cacheRead": 0.1, + "output": 5 + }, + "runs": [ + { + "run": "labels-rx-1", + "task": "labels", + "arm": "rx", + "n": 1, + "input": 18, + "cacheWrite": 58626, + "cacheRead": 406583, + "output": 4781, + "calls": 9, + "billed": 137863.8 + }, + { + "run": "labels-rx-2", + "task": "labels", + "arm": "rx", + "n": 2, + "input": 14, + "cacheWrite": 56399, + "cacheRead": 292356, + "output": 3465, + "calls": 7, + "billed": 117073.35 + }, + { + "run": "labels-rx-3", + "task": "labels", + "arm": "rx", + "n": 3, + "input": 18, + "cacheWrite": 59223, + "cacheRead": 409582, + "output": 4018, + "calls": 9, + "billed": 135094.95 + }, + { + "run": "labels-rx-4", + "task": "labels", + "arm": "rx", + "n": 4, + "input": 16, + "cacheWrite": 65406, + "cacheRead": 382298, + "output": 4469, + "calls": 8, + "billed": 142348.3 + }, + { + "run": "labels-rx-5", + "task": "labels", + "arm": "rx", + "n": 5, + "input": 14, + "cacheWrite": 58041, + "cacheRead": 296776, + "output": 3293, + "calls": 7, + "billed": 118707.85 + }, + { + "run": "labels-rx-6", + "task": "labels", + "arm": "rx", + "n": 6, + "input": 44, + "cacheWrite": 78228, + "cacheRead": 1329643, + "output": 14407, + "calls": 22, + "billed": 302828.30000000005 + }, + { + "run": "labels-react-1", + "task": "labels", + "arm": "react", + "n": 1, + "input": 14, + "cacheWrite": 59794, + "cacheRead": 285026, + "output": 6138, + "calls": 7, + "billed": 133949.1 + }, + { + "run": "labels-react-2", + "task": "labels", + "arm": "react", + "n": 2, + "input": 12, + "cacheWrite": 55002, + "cacheRead": 220808, + "output": 4564, + "calls": 6, + "billed": 113665.3 + }, + { + "run": "labels-react-3", + "task": "labels", + "arm": "react", + "n": 3, + "input": 14, + "cacheWrite": 58336, + "cacheRead": 285995, + "output": 4473, + "calls": 7, + "billed": 123898.5 + }, + { + "run": "labels-react-4", + "task": "labels", + "arm": "react", + "n": 4, + "input": 14, + "cacheWrite": 55573, + "cacheRead": 275432, + "output": 5171, + "calls": 7, + "billed": 122878.45 + }, + { + "run": "labels-react-5", + "task": "labels", + "arm": "react", + "n": 5, + "input": 14, + "cacheWrite": 57290, + "cacheRead": 281596, + "output": 4598, + "calls": 7, + "billed": 122776.1 + }, + { + "run": "labels-react-6", + "task": "labels", + "arm": "react", + "n": 6, + "input": 14, + "cacheWrite": 58288, + "cacheRead": 283831, + "output": 4908, + "calls": 7, + "billed": 125797.1 + }, + { + "run": "labels-solid-1", + "task": "labels", + "arm": "solid", + "n": 1, + "input": 16, + "cacheWrite": 56816, + "cacheRead": 341170, + "output": 4108, + "calls": 8, + "billed": 125693 + }, + { + "run": "labels-solid-2", + "task": "labels", + "arm": "solid", + "n": 2, + "input": 26, + "cacheWrite": 66216, + "cacheRead": 662275, + "output": 8531, + "calls": 13, + "billed": 191678.5 + }, + { + "run": "labels-solid-3", + "task": "labels", + "arm": "solid", + "n": 3, + "input": 24, + "cacheWrite": 65099, + "cacheRead": 612657, + "output": 6361, + "calls": 12, + "billed": 174468.45 + }, + { + "run": "labels-solid-4", + "task": "labels", + "arm": "solid", + "n": 4, + "input": 22, + "cacheWrite": 60813, + "cacheRead": 518376, + "output": 5375, + "calls": 11, + "billed": 154750.85 + }, + { + "run": "labels-solid-5", + "task": "labels", + "arm": "solid", + "n": 5, + "input": 24, + "cacheWrite": 66571, + "cacheRead": 609919, + "output": 7884, + "calls": 12, + "billed": 183649.65 + }, + { + "run": "labels-solid-6", + "task": "labels", + "arm": "solid", + "n": 6, + "input": 24, + "cacheWrite": 68466, + "cacheRead": 613887, + "output": 9331, + "calls": 12, + "billed": 193650.2 + }, + { + "run": "related-rx-1", + "task": "related", + "arm": "rx", + "n": 1, + "input": 30, + "cacheWrite": 37354, + "cacheRead": 811212, + "output": 4983, + "calls": 15, + "billed": 152758.7 + }, + { + "run": "related-rx-2", + "task": "related", + "arm": "rx", + "n": 2, + "input": 14, + "cacheWrite": 34533, + "cacheRead": 340664, + "output": 3080, + "calls": 7, + "billed": 92646.65 + }, + { + "run": "related-rx-3", + "task": "related", + "arm": "rx", + "n": 3, + "input": 14, + "cacheWrite": 30119, + "cacheRead": 329327, + "output": 2825, + "calls": 7, + "billed": 84720.45000000001 + }, + { + "run": "related-rx-4", + "task": "related", + "arm": "rx", + "n": 4, + "input": 16, + "cacheWrite": 29125, + "cacheRead": 371443, + "output": 3237, + "calls": 8, + "billed": 89751.55 + }, + { + "run": "related-rx-5", + "task": "related", + "arm": "rx", + "n": 5, + "input": 14, + "cacheWrite": 32036, + "cacheRead": 335326, + "output": 2792, + "calls": 7, + "billed": 87551.6 + }, + { + "run": "related-rx-6", + "task": "related", + "arm": "rx", + "n": 6, + "input": 16, + "cacheWrite": 31426, + "cacheRead": 378376, + "output": 2677, + "calls": 8, + "billed": 90521.1 + }, + { + "run": "related-react-1", + "task": "related", + "arm": "react", + "n": 1, + "input": 18, + "cacheWrite": 31051, + "cacheRead": 419465, + "output": 4900, + "calls": 9, + "billed": 105278.25 + }, + { + "run": "related-react-2", + "task": "related", + "arm": "react", + "n": 2, + "input": 16, + "cacheWrite": 30477, + "cacheRead": 359747, + "output": 4798, + "calls": 8, + "billed": 98076.95000000001 + }, + { + "run": "related-react-3", + "task": "related", + "arm": "react", + "n": 3, + "input": 16, + "cacheWrite": 28750, + "cacheRead": 357477, + "output": 3742, + "calls": 8, + "billed": 90411.20000000001 + }, + { + "run": "related-react-4", + "task": "related", + "arm": "react", + "n": 4, + "input": 16, + "cacheWrite": 29730, + "cacheRead": 359151, + "output": 4686, + "calls": 8, + "billed": 96523.6 + }, + { + "run": "related-react-5", + "task": "related", + "arm": "react", + "n": 5, + "input": 14, + "cacheWrite": 26107, + "cacheRead": 299006, + "output": 3639, + "calls": 7, + "billed": 80743.35 + }, + { + "run": "related-react-6", + "task": "related", + "arm": "react", + "n": 6, + "input": 16, + "cacheWrite": 32283, + "cacheRead": 365829, + "output": 6479, + "calls": 8, + "billed": 109347.65 + }, + { + "run": "related-solid-1", + "task": "related", + "arm": "solid", + "n": 1, + "input": 16, + "cacheWrite": 30333, + "cacheRead": 372807, + "output": 3540, + "calls": 8, + "billed": 92912.95000000001 + }, + { + "run": "related-solid-2", + "task": "related", + "arm": "solid", + "n": 2, + "input": 20, + "cacheWrite": 37576, + "cacheRead": 506301, + "output": 5746, + "calls": 10, + "billed": 126350.1 + }, + { + "run": "related-solid-3", + "task": "related", + "arm": "solid", + "n": 3, + "input": 16, + "cacheWrite": 31583, + "cacheRead": 372893, + "output": 3859, + "calls": 8, + "billed": 96079.05 + }, + { + "run": "related-solid-4", + "task": "related", + "arm": "solid", + "n": 4, + "input": 16, + "cacheWrite": 31921, + "cacheRead": 375920, + "output": 4436, + "calls": 8, + "billed": 99689.25 + }, + { + "run": "related-solid-5", + "task": "related", + "arm": "solid", + "n": 5, + "input": 20, + "cacheWrite": 38332, + "cacheRead": 505798, + "output": 7833, + "calls": 10, + "billed": 137679.8 + }, + { + "run": "related-solid-6", + "task": "related", + "arm": "solid", + "n": 6, + "input": 16, + "cacheWrite": 32468, + "cacheRead": 379075, + "output": 3720, + "calls": 8, + "billed": 97108.5 + }, + { + "run": "count-rx-1", + "task": "count", + "arm": "rx", + "n": 1, + "input": 28, + "cacheWrite": 61293, + "cacheRead": 923045, + "output": 10960, + "calls": 14, + "billed": 223748.75 + }, + { + "run": "count-rx-2", + "task": "count", + "arm": "rx", + "n": 2, + "input": 18, + "cacheWrite": 45284, + "cacheRead": 498947, + "output": 3743, + "calls": 9, + "billed": 125232.70000000001 + }, + { + "run": "count-rx-3", + "task": "count", + "arm": "rx", + "n": 3, + "input": 18, + "cacheWrite": 36280, + "cacheRead": 460704, + "output": 3350, + "calls": 9, + "billed": 108188.4 + }, + { + "run": "count-rx-4", + "task": "count", + "arm": "rx", + "n": 4, + "input": 22, + "cacheWrite": 39627, + "cacheRead": 601818, + "output": 3978, + "calls": 11, + "billed": 129627.55 + }, + { + "run": "count-rx-5", + "task": "count", + "arm": "rx", + "n": 5, + "input": 16, + "cacheWrite": 36313, + "cacheRead": 404706, + "output": 3415, + "calls": 8, + "billed": 102952.85 + }, + { + "run": "count-rx-6", + "task": "count", + "arm": "rx", + "n": 6, + "input": 20, + "cacheWrite": 38031, + "cacheRead": 530691, + "output": 3913, + "calls": 10, + "billed": 120192.85 + }, + { + "run": "count-react-1", + "task": "count", + "arm": "react", + "n": 1, + "input": 10, + "cacheWrite": 25380, + "cacheRead": 194339, + "output": 4294, + "calls": 5, + "billed": 72638.9 + }, + { + "run": "count-react-2", + "task": "count", + "arm": "react", + "n": 2, + "input": 12, + "cacheWrite": 26411, + "cacheRead": 247919, + "output": 3069, + "calls": 6, + "billed": 73162.65 + }, + { + "run": "count-react-3", + "task": "count", + "arm": "react", + "n": 3, + "input": 12, + "cacheWrite": 24759, + "cacheRead": 247848, + "output": 2344, + "calls": 6, + "billed": 67465.55 + }, + { + "run": "count-react-4", + "task": "count", + "arm": "react", + "n": 4, + "input": 16, + "cacheWrite": 33846, + "cacheRead": 371904, + "output": 9974, + "calls": 8, + "billed": 129383.9 + }, + { + "run": "count-react-5", + "task": "count", + "arm": "react", + "n": 5, + "input": 12, + "cacheWrite": 29445, + "cacheRead": 260999, + "output": 3247, + "calls": 6, + "billed": 79153.15 + }, + { + "run": "count-react-6", + "task": "count", + "arm": "react", + "n": 6, + "input": 14, + "cacheWrite": 26157, + "cacheRead": 300300, + "output": 2812, + "calls": 7, + "billed": 76800.25 + }, + { + "run": "count-solid-1", + "task": "count", + "arm": "solid", + "n": 1, + "input": 16, + "cacheWrite": 37048, + "cacheRead": 407205, + "output": 4529, + "calls": 8, + "billed": 109691.5 + }, + { + "run": "count-solid-2", + "task": "count", + "arm": "solid", + "n": 2, + "input": 26, + "cacheWrite": 47495, + "cacheRead": 726542, + "output": 5967, + "calls": 13, + "billed": 161883.95 + }, + { + "run": "count-solid-3", + "task": "count", + "arm": "solid", + "n": 3, + "input": 22, + "cacheWrite": 35769, + "cacheRead": 559917, + "output": 5043, + "calls": 11, + "billed": 125939.95000000001 + }, + { + "run": "count-solid-4", + "task": "count", + "arm": "solid", + "n": 4, + "input": 20, + "cacheWrite": 33091, + "cacheRead": 490300, + "output": 4346, + "calls": 10, + "billed": 112143.75 + }, + { + "run": "count-solid-5", + "task": "count", + "arm": "solid", + "n": 5, + "input": 22, + "cacheWrite": 32997, + "cacheRead": 543861, + "output": 4645, + "calls": 11, + "billed": 118879.35 + }, + { + "run": "count-solid-6", + "task": "count", + "arm": "solid", + "n": 6, + "input": 24, + "cacheWrite": 33440, + "cacheRead": 606354, + "output": 4890, + "calls": 12, + "billed": 126909.4 + } + ], + "diff": -29992.208333333372, + "ratio": 1.296308770721172, + "p": 0.989040109598904, + "solid": { + "diff": 3741.583333333314, + "ratio": 0.9722749634009017, + "p": 0.4168258317416826 + } +} diff --git a/eval/results/cost-2026-10-29/cost.txt b/eval/results/cost-2026-10-29/cost.txt new file mode 100644 index 0000000..1f6c19f --- /dev/null +++ b/eval/results/cost-2026-10-29/cost.txt @@ -0,0 +1,59 @@ +labels-rx-1 billed 137864 calls 9 out 4781 cacheRead 406583 cacheWrite 58626 +labels-rx-2 billed 117073 calls 7 out 3465 cacheRead 292356 cacheWrite 56399 +labels-rx-3 billed 135095 calls 9 out 4018 cacheRead 409582 cacheWrite 59223 +labels-rx-4 billed 142348 calls 8 out 4469 cacheRead 382298 cacheWrite 65406 +labels-rx-5 billed 118708 calls 7 out 3293 cacheRead 296776 cacheWrite 58041 +labels-rx-6 billed 302828 calls 22 out 14407 cacheRead 1329643 cacheWrite 78228 +labels-react-1 billed 133949 calls 7 out 6138 cacheRead 285026 cacheWrite 59794 +labels-react-2 billed 113665 calls 6 out 4564 cacheRead 220808 cacheWrite 55002 +labels-react-3 billed 123899 calls 7 out 4473 cacheRead 285995 cacheWrite 58336 +labels-react-4 billed 122878 calls 7 out 5171 cacheRead 275432 cacheWrite 55573 +labels-react-5 billed 122776 calls 7 out 4598 cacheRead 281596 cacheWrite 57290 +labels-react-6 billed 125797 calls 7 out 4908 cacheRead 283831 cacheWrite 58288 +labels-solid-1 billed 125693 calls 8 out 4108 cacheRead 341170 cacheWrite 56816 +labels-solid-2 billed 191679 calls 13 out 8531 cacheRead 662275 cacheWrite 66216 +labels-solid-3 billed 174468 calls 12 out 6361 cacheRead 612657 cacheWrite 65099 +labels-solid-4 billed 154751 calls 11 out 5375 cacheRead 518376 cacheWrite 60813 +labels-solid-5 billed 183650 calls 12 out 7884 cacheRead 609919 cacheWrite 66571 +labels-solid-6 billed 193650 calls 12 out 9331 cacheRead 613887 cacheWrite 68466 +related-rx-1 billed 152759 calls 15 out 4983 cacheRead 811212 cacheWrite 37354 +related-rx-2 billed 92647 calls 7 out 3080 cacheRead 340664 cacheWrite 34533 +related-rx-3 billed 84720 calls 7 out 2825 cacheRead 329327 cacheWrite 30119 +related-rx-4 billed 89752 calls 8 out 3237 cacheRead 371443 cacheWrite 29125 +related-rx-5 billed 87552 calls 7 out 2792 cacheRead 335326 cacheWrite 32036 +related-rx-6 billed 90521 calls 8 out 2677 cacheRead 378376 cacheWrite 31426 +related-react-1 billed 105278 calls 9 out 4900 cacheRead 419465 cacheWrite 31051 +related-react-2 billed 98077 calls 8 out 4798 cacheRead 359747 cacheWrite 30477 +related-react-3 billed 90411 calls 8 out 3742 cacheRead 357477 cacheWrite 28750 +related-react-4 billed 96524 calls 8 out 4686 cacheRead 359151 cacheWrite 29730 +related-react-5 billed 80743 calls 7 out 3639 cacheRead 299006 cacheWrite 26107 +related-react-6 billed 109348 calls 8 out 6479 cacheRead 365829 cacheWrite 32283 +related-solid-1 billed 92913 calls 8 out 3540 cacheRead 372807 cacheWrite 30333 +related-solid-2 billed 126350 calls 10 out 5746 cacheRead 506301 cacheWrite 37576 +related-solid-3 billed 96079 calls 8 out 3859 cacheRead 372893 cacheWrite 31583 +related-solid-4 billed 99689 calls 8 out 4436 cacheRead 375920 cacheWrite 31921 +related-solid-5 billed 137680 calls 10 out 7833 cacheRead 505798 cacheWrite 38332 +related-solid-6 billed 97109 calls 8 out 3720 cacheRead 379075 cacheWrite 32468 +count-rx-1 billed 223749 calls 14 out 10960 cacheRead 923045 cacheWrite 61293 +count-rx-2 billed 125233 calls 9 out 3743 cacheRead 498947 cacheWrite 45284 +count-rx-3 billed 108188 calls 9 out 3350 cacheRead 460704 cacheWrite 36280 +count-rx-4 billed 129628 calls 11 out 3978 cacheRead 601818 cacheWrite 39627 +count-rx-5 billed 102953 calls 8 out 3415 cacheRead 404706 cacheWrite 36313 +count-rx-6 billed 120193 calls 10 out 3913 cacheRead 530691 cacheWrite 38031 +count-react-1 billed 72639 calls 5 out 4294 cacheRead 194339 cacheWrite 25380 +count-react-2 billed 73163 calls 6 out 3069 cacheRead 247919 cacheWrite 26411 +count-react-3 billed 67466 calls 6 out 2344 cacheRead 247848 cacheWrite 24759 +count-react-4 billed 129384 calls 8 out 9974 cacheRead 371904 cacheWrite 33846 +count-react-5 billed 79153 calls 6 out 3247 cacheRead 260999 cacheWrite 29445 +count-react-6 billed 76800 calls 7 out 2812 cacheRead 300300 cacheWrite 26157 +count-solid-1 billed 109692 calls 8 out 4529 cacheRead 407205 cacheWrite 37048 +count-solid-2 billed 161884 calls 13 out 5967 cacheRead 726542 cacheWrite 47495 +count-solid-3 billed 125940 calls 11 out 5043 cacheRead 559917 cacheWrite 35769 +count-solid-4 billed 112144 calls 10 out 4346 cacheRead 490300 cacheWrite 33091 +count-solid-5 billed 118879 calls 11 out 4645 cacheRead 543861 cacheWrite 32997 +count-solid-6 billed 126909 calls 12 out 4890 cacheRead 606354 cacheWrite 33440 +react n 18 mean 101219 sd 22425 +rx n 18 mean 131212 sd 53926 +solid n 18 mean 134953 sd 33779 +React - .rx = -29992 (.rx/React 1.296), one-sided permutation p = 0.9890 +Solid - .rx = 3742 (.rx/Solid 0.972), one-sided permutation p = 0.4168 diff --git a/eval/results/cost-2026-10-29/score.json b/eval/results/cost-2026-10-29/score.json new file mode 100644 index 0000000..dbf7b51 --- /dev/null +++ b/eval/results/cost-2026-10-29/score.json @@ -0,0 +1,1088 @@ +[ + { + "run": "count-react-1", + "task": "count", + "arm": "react", + "n": 1, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "count-react-2", + "task": "count", + "arm": "react", + "n": 2, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "count-react-3", + "task": "count", + "arm": "react", + "n": 3, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "count-react-4", + "task": "count", + "arm": "react", + "n": 4, + "checks": 2, + "tests": 1, + "cyclesToGreen": 3, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "count-react-5", + "task": "count", + "arm": "react", + "n": 5, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "count-react-6", + "task": "count", + "arm": "react", + "n": 6, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "count-rx-1", + "task": "count", + "arm": "rx", + "n": 1, + "checks": 2, + "tests": 2, + "cyclesToGreen": 3, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "count-rx-2", + "task": "count", + "arm": "rx", + "n": 2, + "checks": 1, + "tests": 2, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "count-rx-3", + "task": "count", + "arm": "rx", + "n": 3, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "count-rx-4", + "task": "count", + "arm": "rx", + "n": 4, + "checks": 1, + "tests": 2, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "count-rx-5", + "task": "count", + "arm": "rx", + "n": 5, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "count-rx-6", + "task": "count", + "arm": "rx", + "n": 6, + "checks": 1, + "tests": 2, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "count-solid-1", + "task": "count", + "arm": "solid", + "n": 1, + "checks": 2, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "count-solid-2", + "task": "count", + "arm": "solid", + "n": 2, + "checks": 2, + "tests": 2, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "count-solid-3", + "task": "count", + "arm": "solid", + "n": 3, + "checks": 2, + "tests": 2, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "count-solid-4", + "task": "count", + "arm": "solid", + "n": 4, + "checks": 2, + "tests": 2, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "count-solid-5", + "task": "count", + "arm": "solid", + "n": 5, + "checks": 1, + "tests": 2, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "count-solid-6", + "task": "count", + "arm": "solid", + "n": 6, + "checks": 1, + "tests": 3, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "labels-react-1", + "task": "labels", + "arm": "react", + "n": 1, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "22/22", + "hidden": "58/58", + "overlap": "26/26", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "labels-react-2", + "task": "labels", + "arm": "react", + "n": 2, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "22/22", + "hidden": "58/58", + "overlap": "26/26", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "labels-react-3", + "task": "labels", + "arm": "react", + "n": 3, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "22/22", + "hidden": "58/58", + "overlap": "26/26", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "labels-react-4", + "task": "labels", + "arm": "react", + "n": 4, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "22/22", + "hidden": "58/58", + "overlap": "26/26", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "labels-react-5", + "task": "labels", + "arm": "react", + "n": 5, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "22/22", + "hidden": "58/58", + "overlap": "26/26", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "labels-react-6", + "task": "labels", + "arm": "react", + "n": 6, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "22/22", + "hidden": "58/58", + "overlap": "26/26", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "labels-rx-1", + "task": "labels", + "arm": "rx", + "n": 1, + "checks": 2, + "tests": 1, + "cyclesToGreen": 3, + "checkOk": true, + "built": true, + "visible": "22/22", + "hidden": "58/58", + "overlap": "26/26", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "labels-rx-2", + "task": "labels", + "arm": "rx", + "n": 2, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "22/22", + "hidden": "58/58", + "overlap": "26/26", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "labels-rx-3", + "task": "labels", + "arm": "rx", + "n": 3, + "checks": 2, + "tests": 1, + "cyclesToGreen": 3, + "checkOk": true, + "built": true, + "visible": "22/22", + "hidden": "58/58", + "overlap": "26/26", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "labels-rx-4", + "task": "labels", + "arm": "rx", + "n": 4, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "22/22", + "hidden": "58/58", + "overlap": "26/26", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "labels-rx-5", + "task": "labels", + "arm": "rx", + "n": 5, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "22/22", + "hidden": "58/58", + "overlap": "26/26", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "labels-rx-6", + "task": "labels", + "arm": "rx", + "n": 6, + "checks": 7, + "tests": 8, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "22/22", + "hidden": "58/58", + "overlap": "26/26", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "labels-solid-1", + "task": "labels", + "arm": "solid", + "n": 1, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "22/22", + "hidden": "58/58", + "overlap": "26/26", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "labels-solid-2", + "task": "labels", + "arm": "solid", + "n": 2, + "checks": 3, + "tests": 4, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "22/22", + "hidden": "58/58", + "overlap": "26/26", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "labels-solid-3", + "task": "labels", + "arm": "solid", + "n": 3, + "checks": 2, + "tests": 4, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "22/22", + "hidden": "58/58", + "overlap": "26/26", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "labels-solid-4", + "task": "labels", + "arm": "solid", + "n": 4, + "checks": 2, + "tests": 3, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "22/22", + "hidden": "58/58", + "overlap": "26/26", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "labels-solid-5", + "task": "labels", + "arm": "solid", + "n": 5, + "checks": 2, + "tests": 3, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "22/22", + "hidden": "58/58", + "overlap": "26/26", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "labels-solid-6", + "task": "labels", + "arm": "solid", + "n": 6, + "checks": 2, + "tests": 4, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "22/22", + "hidden": "58/58", + "overlap": "26/26", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "related-react-1", + "task": "related", + "arm": "react", + "n": 1, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "related-react-2", + "task": "related", + "arm": "react", + "n": 2, + "checks": 2, + "tests": 1, + "cyclesToGreen": 3, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "related-react-3", + "task": "related", + "arm": "react", + "n": 3, + "checks": 2, + "tests": 1, + "cyclesToGreen": 3, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "related-react-4", + "task": "related", + "arm": "react", + "n": 4, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "related-react-5", + "task": "related", + "arm": "react", + "n": 5, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "related-react-6", + "task": "related", + "arm": "react", + "n": 6, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [], + "failedVisible": [] + }, + { + "run": "related-rx-1", + "task": "related", + "arm": "rx", + "n": 1, + "checks": 2, + "tests": 2, + "cyclesToGreen": 4, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [ + { + "name": "base: retry after not found shows the issue", + "error": "expected [detail] to show \"stack trace attached\", it shows \"detail error: issue #3 not foundretryassign to guestrenamecloseloading commentspost\"" + } + ], + "failedVisible": [] + }, + { + "run": "related-rx-2", + "task": "related", + "arm": "rx", + "n": 2, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [ + { + "name": "base: retry after not found shows the issue", + "error": "expected [detail] to show \"stack trace attached\", it shows \"detail error: issue #3 not foundretryassign to guestrenamecloseloading commentspost\"" + } + ], + "failedVisible": [] + }, + { + "run": "related-rx-3", + "task": "related", + "arm": "rx", + "n": 3, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [ + { + "name": "base: retry after not found shows the issue", + "error": "expected [detail] to show \"stack trace attached\", it shows \"detail error: issue #3 not foundretryassign to guestrenamecloseloading commentspost\"" + } + ], + "failedVisible": [] + }, + { + "run": "related-rx-4", + "task": "related", + "arm": "rx", + "n": 4, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [ + { + "name": "base: retry after not found shows the issue", + "error": "expected [detail] to show \"stack trace attached\", it shows \"detail error: issue #3 not foundretryassign to guestrenamecloseloading commentspost\"" + } + ], + "failedVisible": [] + }, + { + "run": "related-rx-5", + "task": "related", + "arm": "rx", + "n": 5, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [ + { + "name": "base: retry after not found shows the issue", + "error": "expected [detail] to show \"stack trace attached\", it shows \"detail error: issue #3 not foundretryassign to guestrenamecloseloading commentspost\"" + } + ], + "failedVisible": [] + }, + { + "run": "related-rx-6", + "task": "related", + "arm": "rx", + "n": 6, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [ + { + "name": "base: retry after not found shows the issue", + "error": "expected [detail] to show \"stack trace attached\", it shows \"detail error: issue #3 not foundretryassign to guestrenamecloseloading commentspost\"" + } + ], + "failedVisible": [] + }, + { + "run": "related-solid-1", + "task": "related", + "arm": "solid", + "n": 1, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [ + { + "name": "base: retry after not found shows the issue", + "error": "expected [detail] to show \"stack trace attached\", it shows \"detail error: issue #3 not foundretryassign to guestrenamecloseloading commentspost\"" + } + ], + "failedVisible": [] + }, + { + "run": "related-solid-2", + "task": "related", + "arm": "solid", + "n": 2, + "checks": 1, + "tests": 2, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [ + { + "name": "base: retry after not found shows the issue", + "error": "expected [detail] to show \"stack trace attached\", it shows \"detail error: issue #3 not foundretryassign to guestrenamecloseloading commentspost\"" + } + ], + "failedVisible": [] + }, + { + "run": "related-solid-3", + "task": "related", + "arm": "solid", + "n": 3, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [ + { + "name": "base: retry after not found shows the issue", + "error": "expected [detail] to show \"stack trace attached\", it shows \"detail error: issue #3 not foundretryassign to guestrenamecloseloading commentspost\"" + } + ], + "failedVisible": [] + }, + { + "run": "related-solid-4", + "task": "related", + "arm": "solid", + "n": 4, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [ + { + "name": "base: retry after not found shows the issue", + "error": "expected [detail] to show \"stack trace attached\", it shows \"detail error: issue #3 not foundretryassign to guestrenamecloseloading commentspost\"" + } + ], + "failedVisible": [] + }, + { + "run": "related-solid-5", + "task": "related", + "arm": "solid", + "n": 5, + "checks": 2, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [ + { + "name": "base: retry after not found shows the issue", + "error": "expected [detail] to show \"stack trace attached\", it shows \"detail error: issue #3 not foundretryassign to guestrenamecloseloading commentspost\"" + } + ], + "failedVisible": [] + }, + { + "run": "related-solid-6", + "task": "related", + "arm": "solid", + "n": 6, + "checks": 1, + "tests": 1, + "cyclesToGreen": 2, + "checkOk": true, + "built": true, + "visible": "21/21", + "hidden": "56/56", + "overlap": "24/24", + "failedOverlap": [], + "shippedDefect": false, + "failedHidden": [], + "failedAlongside": [ + { + "name": "base: retry after not found shows the issue", + "error": "expected [detail] to show \"stack trace attached\", it shows \"detail error: issue #3 not foundretryassign to guestrenamecloseloading commentspost\"" + } + ], + "failedVisible": [] + } +] diff --git a/eval/results/cost-2026-10-29/secondary.json b/eval/results/cost-2026-10-29/secondary.json new file mode 100644 index 0000000..3a08494 --- /dev/null +++ b/eval/results/cost-2026-10-29/secondary.json @@ -0,0 +1,866 @@ +[ + { + "run": "labels-rx-1", + "task": "labels", + "arm": "rx", + "billed": 137863.8, + "calls": 9, + "output": 4781, + "fixed": 36408, + "checks": 2, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 142, + "usd": 0.689319 + }, + { + "run": "labels-rx-2", + "task": "labels", + "arm": "rx", + "billed": 117073.35, + "calls": 7, + "output": 3465, + "fixed": 36408, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 111, + "usd": 0.58536675 + }, + { + "run": "labels-rx-3", + "task": "labels", + "arm": "rx", + "billed": 135094.95, + "calls": 9, + "output": 4018, + "fixed": 36408, + "checks": 2, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 130, + "usd": 0.6754747500000001 + }, + { + "run": "labels-rx-4", + "task": "labels", + "arm": "rx", + "billed": 142348.3, + "calls": 8, + "output": 4469, + "fixed": 36408, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 121, + "usd": 0.7117415 + }, + { + "run": "labels-rx-5", + "task": "labels", + "arm": "rx", + "billed": 118707.84999999999, + "calls": 7, + "output": 3293, + "fixed": 36408, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 106, + "usd": 0.59353925 + }, + { + "run": "labels-rx-6", + "task": "labels", + "arm": "rx", + "billed": 302828.3, + "calls": 22, + "output": 14407, + "fixed": 36408, + "checks": 7, + "tests": 8, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 515, + "usd": 1.5141415 + }, + { + "run": "labels-react-1", + "task": "labels", + "arm": "react", + "billed": 133949.09999999998, + "calls": 7, + "output": 6138, + "fixed": 36407, + "checks": 1, + "tests": 1, + "reads": 0, + "readsDocs": 0, + "mcp": 0, + "secs": 169, + "usd": 0.6697455 + }, + { + "run": "labels-react-2", + "task": "labels", + "arm": "react", + "billed": 113665.3, + "calls": 6, + "output": 4564, + "fixed": 36407, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 164, + "usd": 0.5683265000000001 + }, + { + "run": "labels-react-3", + "task": "labels", + "arm": "react", + "billed": 123898.49999999999, + "calls": 7, + "output": 4473, + "fixed": 36407, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 163, + "usd": 0.6194925 + }, + { + "run": "labels-react-4", + "task": "labels", + "arm": "react", + "billed": 122878.45, + "calls": 7, + "output": 5171, + "fixed": 36407, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 167, + "usd": 0.6143922500000001 + }, + { + "run": "labels-react-5", + "task": "labels", + "arm": "react", + "billed": 122776.1, + "calls": 7, + "output": 4598, + "fixed": 36407, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 164, + "usd": 0.6138805 + }, + { + "run": "labels-react-6", + "task": "labels", + "arm": "react", + "billed": 125797.09999999999, + "calls": 7, + "output": 4908, + "fixed": 36407, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 165, + "usd": 0.6289855 + }, + { + "run": "labels-solid-1", + "task": "labels", + "arm": "solid", + "billed": 125693, + "calls": 8, + "output": 4108, + "fixed": 36408, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 152, + "usd": 0.628465 + }, + { + "run": "labels-solid-2", + "task": "labels", + "arm": "solid", + "billed": 191678.50000000003, + "calls": 13, + "output": 8531, + "fixed": 36408, + "checks": 3, + "tests": 4, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 299, + "usd": 0.9583925000000002 + }, + { + "run": "labels-solid-3", + "task": "labels", + "arm": "solid", + "billed": 174468.45000000004, + "calls": 12, + "output": 6361, + "fixed": 36408, + "checks": 2, + "tests": 4, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 271, + "usd": 0.8723422500000003 + }, + { + "run": "labels-solid-4", + "task": "labels", + "arm": "solid", + "billed": 154750.84999999998, + "calls": 11, + "output": 5375, + "fixed": 36408, + "checks": 2, + "tests": 3, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 229, + "usd": 0.77375425 + }, + { + "run": "labels-solid-5", + "task": "labels", + "arm": "solid", + "billed": 183649.65000000002, + "calls": 12, + "output": 7884, + "fixed": 36408, + "checks": 2, + "tests": 3, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 263, + "usd": 0.9182482500000002 + }, + { + "run": "labels-solid-6", + "task": "labels", + "arm": "solid", + "billed": 193650.19999999998, + "calls": 12, + "output": 9331, + "fixed": 36408, + "checks": 2, + "tests": 4, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 296, + "usd": 0.968251 + }, + { + "run": "related-rx-1", + "task": "related", + "arm": "rx", + "billed": 152758.7, + "calls": 15, + "output": 4983, + "fixed": 36407, + "checks": 2, + "tests": 3, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 160, + "usd": 0.7637935000000001 + }, + { + "run": "related-rx-2", + "task": "related", + "arm": "rx", + "billed": 92646.65, + "calls": 7, + "output": 3080, + "fixed": 36407, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 90, + "usd": 0.46323325 + }, + { + "run": "related-rx-3", + "task": "related", + "arm": "rx", + "billed": 84720.45, + "calls": 7, + "output": 2825, + "fixed": 36407, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 78, + "usd": 0.42360225 + }, + { + "run": "related-rx-4", + "task": "related", + "arm": "rx", + "billed": 89751.55, + "calls": 8, + "output": 3237, + "fixed": 36407, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 95, + "usd": 0.44875775000000007 + }, + { + "run": "related-rx-5", + "task": "related", + "arm": "rx", + "billed": 87551.6, + "calls": 7, + "output": 2792, + "fixed": 36407, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 79, + "usd": 0.4377580000000001 + }, + { + "run": "related-rx-6", + "task": "related", + "arm": "rx", + "billed": 90521.09999999999, + "calls": 8, + "output": 2677, + "fixed": 36407, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 79, + "usd": 0.4526055 + }, + { + "run": "related-react-1", + "task": "related", + "arm": "react", + "billed": 105278.25000000001, + "calls": 9, + "output": 4900, + "fixed": 36406, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 148, + "usd": 0.5263912500000001 + }, + { + "run": "related-react-2", + "task": "related", + "arm": "react", + "billed": 98076.95, + "calls": 8, + "output": 4798, + "fixed": 36406, + "checks": 2, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 162, + "usd": 0.49038475000000004 + }, + { + "run": "related-react-3", + "task": "related", + "arm": "react", + "billed": 90411.2, + "calls": 8, + "output": 3742, + "fixed": 36406, + "checks": 2, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 159, + "usd": 0.452056 + }, + { + "run": "related-react-4", + "task": "related", + "arm": "react", + "billed": 96523.59999999999, + "calls": 8, + "output": 4686, + "fixed": 36406, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 145, + "usd": 0.482618 + }, + { + "run": "related-react-5", + "task": "related", + "arm": "react", + "billed": 80743.35, + "calls": 7, + "output": 3639, + "fixed": 36406, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 134, + "usd": 0.40371675000000007 + }, + { + "run": "related-react-6", + "task": "related", + "arm": "react", + "billed": 109347.65000000001, + "calls": 8, + "output": 6479, + "fixed": 36406, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 157, + "usd": 0.5467382500000001 + }, + { + "run": "related-solid-1", + "task": "related", + "arm": "solid", + "billed": 92912.95, + "calls": 8, + "output": 3540, + "fixed": 36407, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 117, + "usd": 0.46456475 + }, + { + "run": "related-solid-2", + "task": "related", + "arm": "solid", + "billed": 126350.1, + "calls": 10, + "output": 5746, + "fixed": 36407, + "checks": 1, + "tests": 2, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 177, + "usd": 0.6317505000000001 + }, + { + "run": "related-solid-3", + "task": "related", + "arm": "solid", + "billed": 96079.05, + "calls": 8, + "output": 3859, + "fixed": 36407, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 122, + "usd": 0.48039525000000005 + }, + { + "run": "related-solid-4", + "task": "related", + "arm": "solid", + "billed": 99689.25, + "calls": 8, + "output": 4436, + "fixed": 36407, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 133, + "usd": 0.49844625000000004 + }, + { + "run": "related-solid-5", + "task": "related", + "arm": "solid", + "billed": 137679.8, + "calls": 10, + "output": 7833, + "fixed": 36407, + "checks": 2, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 168, + "usd": 0.688399 + }, + { + "run": "related-solid-6", + "task": "related", + "arm": "solid", + "billed": 97108.5, + "calls": 8, + "output": 3720, + "fixed": 36407, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 127, + "usd": 0.48554250000000004 + }, + { + "run": "count-rx-1", + "task": "count", + "arm": "rx", + "billed": 223748.75, + "calls": 14, + "output": 10960, + "fixed": 36407, + "checks": 2, + "tests": 2, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 200, + "usd": 1.1187437500000001 + }, + { + "run": "count-rx-2", + "task": "count", + "arm": "rx", + "billed": 125232.7, + "calls": 9, + "output": 3743, + "fixed": 36407, + "checks": 1, + "tests": 2, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 147, + "usd": 0.6261635 + }, + { + "run": "count-rx-3", + "task": "count", + "arm": "rx", + "billed": 108188.4, + "calls": 9, + "output": 3350, + "fixed": 36407, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 99, + "usd": 0.540942 + }, + { + "run": "count-rx-4", + "task": "count", + "arm": "rx", + "billed": 129627.55, + "calls": 11, + "output": 3978, + "fixed": 36407, + "checks": 1, + "tests": 2, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 153, + "usd": 0.6481377500000001 + }, + { + "run": "count-rx-5", + "task": "count", + "arm": "rx", + "billed": 102952.85, + "calls": 8, + "output": 3415, + "fixed": 36407, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 99, + "usd": 0.51476425 + }, + { + "run": "count-rx-6", + "task": "count", + "arm": "rx", + "billed": 120192.85, + "calls": 10, + "output": 3913, + "fixed": 36407, + "checks": 1, + "tests": 2, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 144, + "usd": 0.6009642500000001 + }, + { + "run": "count-react-1", + "task": "count", + "arm": "react", + "billed": 72638.9, + "calls": 5, + "output": 4294, + "fixed": 36406, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 139, + "usd": 0.3631945 + }, + { + "run": "count-react-2", + "task": "count", + "arm": "react", + "billed": 73162.65, + "calls": 6, + "output": 3069, + "fixed": 36406, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 130, + "usd": 0.36581325 + }, + { + "run": "count-react-3", + "task": "count", + "arm": "react", + "billed": 67465.55, + "calls": 6, + "output": 2344, + "fixed": 36406, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 83, + "usd": 0.33732775000000004 + }, + { + "run": "count-react-4", + "task": "count", + "arm": "react", + "billed": 129383.9, + "calls": 8, + "output": 9974, + "fixed": 36406, + "checks": 2, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 183, + "usd": 0.6469195 + }, + { + "run": "count-react-5", + "task": "count", + "arm": "react", + "billed": 79153.15, + "calls": 6, + "output": 3247, + "fixed": 36406, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 131, + "usd": 0.39576575 + }, + { + "run": "count-react-6", + "task": "count", + "arm": "react", + "billed": 76800.25, + "calls": 7, + "output": 2812, + "fixed": 36406, + "checks": 1, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 122, + "usd": 0.38400125 + }, + { + "run": "count-solid-1", + "task": "count", + "arm": "solid", + "billed": 109691.5, + "calls": 8, + "output": 4529, + "fixed": 36407, + "checks": 2, + "tests": 1, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 148, + "usd": 0.5484575 + }, + { + "run": "count-solid-2", + "task": "count", + "arm": "solid", + "billed": 161883.94999999998, + "calls": 13, + "output": 5967, + "fixed": 36407, + "checks": 2, + "tests": 2, + "reads": 2, + "readsDocs": 0, + "mcp": 0, + "secs": 190, + "usd": 0.80941975 + }, + { + "run": "count-solid-3", + "task": "count", + "arm": "solid", + "billed": 125939.95, + "calls": 11, + "output": 5043, + "fixed": 36407, + "checks": 2, + "tests": 2, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 181, + "usd": 0.62969975 + }, + { + "run": "count-solid-4", + "task": "count", + "arm": "solid", + "billed": 112143.75, + "calls": 10, + "output": 4346, + "fixed": 36407, + "checks": 2, + "tests": 2, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 173, + "usd": 0.56071875 + }, + { + "run": "count-solid-5", + "task": "count", + "arm": "solid", + "billed": 118879.34999999999, + "calls": 11, + "output": 4645, + "fixed": 36407, + "checks": 1, + "tests": 2, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 170, + "usd": 0.59439675 + }, + { + "run": "count-solid-6", + "task": "count", + "arm": "solid", + "billed": 126909.4, + "calls": 12, + "output": 4890, + "fixed": 36407, + "checks": 1, + "tests": 3, + "reads": 1, + "readsDocs": 0, + "mcp": 0, + "secs": 191, + "usd": 0.634547 + } +] diff --git a/eval/results/cost-2026-10-29/secondary.txt b/eval/results/cost-2026-10-29/secondary.txt new file mode 100644 index 0000000..ce45d6c --- /dev/null +++ b/eval/results/cost-2026-10-29/secondary.txt @@ -0,0 +1,13 @@ +group n mean median calls out fixed checks tests reads docs mcp secs usd +ALL/react 18 101219 101678 7.1 4658 36406 1.2 1.0 0.9 0.0 0 158 0.51 +ALL/rx 18 131212 119450 9.7 4633 36407 1.6 1.7 1.0 0.0 0 116 0.66 +ALL/solid 18 134953 126145 10.3 5564 36407 1.6 2.1 1.1 0.0 0 175 0.67 +count/react 6 83101 74981 6.3 4290 36406 1.2 1.0 1.0 0.0 0 131 0.42 +count/rx 6 134991 122713 10.2 4893 36407 1.2 1.7 1.0 0.0 0 146 0.67 +count/solid 6 125908 122410 10.8 4903 36407 1.7 2.0 1.2 0.0 0 177 0.63 +labels/react 6 123827 123388 6.8 4975 36407 1.0 1.0 0.8 0.0 0 165 0.62 +labels/rx 6 158986 136479 10.3 5739 36408 2.3 2.2 1.0 0.0 0 126 0.79 +labels/solid 6 170648 179059 11.3 6932 36408 2.0 3.2 1.0 0.0 0 267 0.85 +related/react 6 96730 97300 8.0 4707 36406 1.3 1.0 1.0 0.0 0 153 0.48 +related/rx 6 99658 90136 8.7 3266 36407 1.2 1.3 1.0 0.0 0 85 0.50 +related/solid 6 108303 98399 8.7 4856 36407 1.2 1.2 1.0 0.0 0 130 0.54 diff --git a/eval/tasks/chain1/BASE.md b/eval/tasks/chain1/BASE.md new file mode 100644 index 0000000..e6a26eb --- /dev/null +++ b/eval/tasks/chain1/BASE.md @@ -0,0 +1,143 @@ +# Issue desk + +Build an issue desk: three pages behind a row of tabs. The issues page is an issue tracker with assigning, renaming, +closing and comments; the help page searches a help catalogue; the settings page edits the user's settings. + +## The API (`src/api.js`, given; do not change it) + +Issues: + +- `fetchIssues(filter: "open" | "closed"): Promise<{ id: number; title: string; assignee: string | null }[]>`. May + reject with an `Error`. +- `fetchIssue(id: number): Promise<{ id: number; title: string; body: string } | null>`: `null` when there is no such + issue. May reject with an `Error`. +- `assign(id: number, user: string): Promise<{ id: number; assignee: string }>`: assigns the issue on the server. May + reject with an `Error` (the server declined). +- `renameIssue(id: number, title: string): Promise<{ id: number; title: string }>`: renames the issue on the server. + May reject with an `Error` (the server declined). +- `closeIssue(id: number): Promise<{ id: number }>`: closes the issue on the server. May reject with an `Error` (the + server declined). +- `fetchComments(id: number): Promise<{ author: string; text: string }[]>`: the issue's comments, oldest first. May + reject with an `Error`. +- `addComment(id: number, author: string, text: string): Promise<{ author: string; text: string }>`: adds a comment + on the server. May reject with an `Error` (the server declined). + +Help: + +- `search(q: string): Promise<{ id: number; name: string }[]>`: the hits for `q`, in order. It may reject with an + `Error`. +- `fetchItem(id: number): Promise<{ id: number; name: string; blurb: string } | null>`: one item, or `null` when there + is no item with that id. It may reject with an `Error`. + +Settings: + +- `loadSettings(): Promise<{ name: string; theme: "light" | "dark"; density: "comfortable" | "compact" }>`. May + reject with an `Error`. +- `saveSettings(s: { name: string; theme: "light" | "dark"; density: "comfortable" | "compact" }): Promise`: + answers the settings as saved. May reject with an `Error`. + +## What the app must do + +Elements the tests look for carry these `data-testid`s. Text in quotes must appear exactly as written. + +### Tabs + +1. Three buttons, `tab-issues`, `tab-help` and `tab-settings`, always on screen. The app opens on the issues page. +2. Only the open page is on screen. Leaving a page discards it: opening it again starts it as it starts when the app + opens (it loads again, nothing selected, inputs at their starting values). A request a discarded page made may + still answer; it must not show on, or change, any page. + +### The issues page + +1. **Current user.** A text input, `user`, holding the current user's name. It starts as `guest`. +2. **Filter.** Two buttons, `filter-open` and `filter-closed`. The filter starts as `open`. +3. **List**, `list`, of the issues for the current filter, from `fetchIssues(filter)`. + - While they load (when the page opens or the filter changes): `loading issues` (`loading closed issues`). + - Then one row per issue, `data-testid="issue"`, reading `# (<assignee>)`, or `(unassigned)` when the + assignee is `null`: `#3 Crash on save (unassigned)`. + - If it fails: `list error: <message>`. +4. **Detail**, `detail`. + - Nothing selected: `select an issue`. Clicking a row selects its issue. + - The selected issue comes from `fetchIssue(id)`. While it loads: `loading #<id>`. + - An issue: `#<id> <title>` and its body. + - `null`: `detail error: issue #<id> not found`. A failure: `detail error: <message>`. + - A button `retry` (`data-testid="retry"`) calls `fetchIssue(id)` again and shows its answer. +5. **Assign.** While an issue is selected, the detail has a button `assign` (`data-testid="assign"`) reading + `assign to <current user>`. Clicking it: + - shows the current user as the issue's assignee in its list row straight away; + - calls `assign(id, user)`; + - if the server declines, the row goes back to the assignee it had before, and the detail shows the server's + message (`declined`); + - if the server accepts, the row keeps the new assignee. + - An assignment error is cleared when a new assignment starts or another issue is selected. +6. **Rename.** While an issue is selected, the detail has a text input `title-input`, empty when the issue is + selected, and a button `rename` (`data-testid="rename"`). Clicking `rename` while `title-input` holds a title: + - shows the new title in the issue's list row and in the detail's heading (`#<id> <title>`) straight away; + - calls `renameIssue(id, title)`; + - if the server declines, the row and the heading go back to the title they had before, and the detail shows + `rename error: <message>` (`rename error: declined`); + - if the server accepts, the row and the heading keep the new title. + - A rename error is cleared when a new rename starts or another issue is selected. +7. **Close.** While an issue is selected, the detail has a button `close` (`data-testid="close"`). Clicking it: + - removes the issue's row from the list of open issues straight away (the detail keeps showing the issue); + - calls `closeIssue(id)`; + - if the server declines, the row comes back where it was, and the detail shows `close error: <message>` + (`close error: declined`); + - if the server accepts, the row stays out of the open issues. + - A close error is cleared when a new close starts or another issue is selected. +8. **Comments.** While an issue is selected, the detail has an element `comments` with that issue's comments, from + `fetchComments(id)`. + - While they load: `loading comments`. + - Then one row per comment, `data-testid="comment"`, reading `<author>: <text>` (`dana: seen it too`). + - If it fails: `comments error: <message>`. +9. **Posting.** The detail has a text input `comment-input` and a button `post` (`data-testid="post"`). Clicking + `post` while `comment-input` holds text: + - adds the comment as the last row straight away, reading `<current user>: <text> (sending…)`, and empties the + input; + - calls `addComment(id, user, text)`; + - if the server declines, the row goes away, and the detail shows `post error: <message>` (`post error: declined`); + - if the server accepts, the row stays and reads `<current user>: <text>`. + - A post error is cleared when a new post starts or another issue is selected. + +### The help page + +1. A text input, `query`. What the user types is the query. +2. A results area, `results`. + - Empty query: the results area shows nothing, and nothing is asked. + - A query of one character: the results area shows `too short: <q>` (for `a`: `too short: a`). Nothing is asked. + - Two characters or more: call `search(q)` and show `searching…` until the results arrive. Results already on + screen stay there until the new ones replace them. + - The results: a line `<n> results for <q>` (`2 results for ap`) and one list item per hit, `data-testid="hit"`, + whose text is the hit's name. + - If the search fails: `search failed: <message>`. +3. Clicking a hit selects it. The selected hit's text starts with `> ` (`> apple pie`); the others have no prefix. +4. A help pane, `item`. + - Nothing selected: `pick a result`. + - When a hit is selected, call `fetchItem(id)` and show `loading…` until it answers. + - The answer is an item: the pane shows its name and its blurb. + - The answer is `null`: `item <id> not found`. + - It fails: `failed: <message>` and a button `retry-item` (`data-testid="retry-item"`) that calls `fetchItem(id)` + again and shows its answer. + +### The settings page + +1. **Loading.** Call `loadSettings()` when the page opens. Until it answers, an element `load` shows + `Loading settings…`. If it fails, `load` shows `Could not load settings: <message>` and a button `retry-load` + calls `loadSettings()` again and shows the settings it answers. +2. **Saved line**, `saved`, once the settings have loaded: `Saved: <name> · <theme> · <density>` + (`Saved: Ann · light · compact`). +3. **The draft.** Once loaded, a form is filled from the loaded settings and then belongs to the user: + - an input `name` (the display name), a counter `name-count` reading `<length>/20`; + - `name-error`, shown only when the name is invalid: `Display name is required` when empty, + `Display name must be at most 20 characters, not <length>` when longer than 20; + - buttons `theme-light` and `theme-dark` that set the draft's theme; a button `density-toggle` that switches the + draft's density between `comfortable` and `compact`; + - `preview` reading `Preview: <draft theme> / <draft density>`. +4. **Save**, a button `save`. + - With an invalid name it does nothing (nothing is asked). + - Otherwise it calls `saveSettings(draft)`. Straight away the saved line shows the values being saved followed by + ` (saving…)` (`Saved: Bea · dark · compact (saving…)`), and any previous save error disappears. + - When the save succeeds, the saved line shows the saved settings, without `(saving…)`. + - When it fails, the saved line goes back to what was saved before, without `(saving…)`, and `save-error` shows + `Save failed: <message>`. + - Saving never changes the draft. diff --git a/eval/tasks/chain1/api.d.rx b/eval/tasks/chain1/api.d.rx new file mode 100644 index 0000000..f66befd --- /dev/null +++ b/eval/tasks/chain1/api.d.rx @@ -0,0 +1,23 @@ +// src/api.js, as .rx sees it. fetchIssues, fetchIssue, fetchComments, fetchLabels, search, fetchItem and loadSettings +// are loaders (safe to ask from an equation); assign, renameIssue, closeIssue, addComment, addLabel, removeLabel and +// saveSettings write. +export type Issue = { id: number; title: string; assignee: string | null } +export type IssueDetail = { id: number; title: string; body: string } +export pure fn fetchIssues(filter: string): Promise<Issue[]> +export pure fn fetchIssue(id: number): Promise<IssueDetail | null> +export fn assign(id: number, user: string): Promise<{ id: number; assignee: string }> +export fn renameIssue(id: number, title: string): Promise<{ id: number; title: string }> +export fn closeIssue(id: number): Promise<{ id: number }> +export type Comment = { author: string; text: string } +export pure fn fetchComments(id: number): Promise<Comment[]> +export fn addComment(id: number, author: string, text: string): Promise<Comment> +export type Hit = { id: number; name: string } +export type Item = { id: number; name: string; blurb: string } +export pure fn search(q: string): Promise<Hit[]> +export pure fn fetchItem(id: number): Promise<Item | null> +export type Settings = { name: string; theme: string; density: string } +export pure fn loadSettings(): Promise<Settings> +export fn saveSettings(s: Settings): Promise<Settings> +export pure fn fetchLabels(id: number): Promise<string[]> +export fn addLabel(id: number, label: string): Promise<string[]> +export fn removeLabel(id: number, label: string): Promise<string[]> diff --git a/eval/tasks/chain1/api.d.ts b/eval/tasks/chain1/api.d.ts new file mode 100644 index 0000000..0acb5a4 --- /dev/null +++ b/eval/tasks/chain1/api.d.ts @@ -0,0 +1,22 @@ +export type Issue = { id: number; title: string; assignee: string | null }; +export type IssueDetail = { id: number; title: string; body: string }; +export function fetchIssues(filter: "open" | "closed"): Promise<Issue[]>; +export function fetchIssue(id: number): Promise<IssueDetail | null>; +export function assign(id: number, user: string): Promise<{ id: number; assignee: string }>; +export function renameIssue(id: number, title: string): Promise<{ id: number; title: string }>; +export function closeIssue(id: number): Promise<{ id: number }>; +export type Comment = { author: string; text: string }; +export function fetchComments(id: number): Promise<Comment[]>; +export function addComment(id: number, author: string, text: string): Promise<Comment>; +export type Hit = { id: number; name: string }; +export type Item = { id: number; name: string; blurb: string }; +export function search(q: string): Promise<Hit[]>; +export function fetchItem(id: number): Promise<Item | null>; +export type Theme = "light" | "dark"; +export type Density = "comfortable" | "compact"; +export type Settings = { name: string; theme: Theme; density: Density }; +export function loadSettings(): Promise<Settings>; +export function saveSettings(s: Settings): Promise<Settings>; +export function fetchLabels(id: number): Promise<string[]>; +export function addLabel(id: number, label: string): Promise<string[]>; +export function removeLabel(id: number, label: string): Promise<string[]>; diff --git a/eval/tasks/chain1/api.js b/eval/tasks/chain1/api.js new file mode 100644 index 0000000..ac4c81c --- /dev/null +++ b/eval/tasks/chain1/api.js @@ -0,0 +1,16 @@ +import { flight } from "./flights.js"; + +export const fetchIssues = filter => flight("fetchIssues", [filter]); +export const fetchIssue = id => flight("fetchIssue", [id]); +export const assign = (id, user) => flight("assign", [id, user]); +export const renameIssue = (id, title) => flight("renameIssue", [id, title]); +export const closeIssue = id => flight("closeIssue", [id]); +export const fetchComments = id => flight("fetchComments", [id]); +export const addComment = (id, author, text) => flight("addComment", [id, author, text]); +export const search = q => flight("search", [q]); +export const fetchItem = id => flight("fetchItem", [id]); +export const loadSettings = () => flight("loadSettings", []); +export const saveSettings = s => flight("saveSettings", [s]); +export const fetchLabels = id => flight("fetchLabels", [id]); +export const addLabel = (id, label) => flight("addLabel", [id, label]); +export const removeLabel = (id, label) => flight("removeLabel", [id, label]); diff --git a/eval/tasks/chain1/brief-plain.md b/eval/tasks/chain1/brief-plain.md new file mode 100644 index 0000000..d1bb8ce --- /dev/null +++ b/eval/tasks/chain1/brief-plain.md @@ -0,0 +1,39 @@ +# Change request: labels + +The app in `src/` is an issue desk that already works. `BASE.md` is the brief it was built to. Keep everything it +does, and add labels to the selected issue. + +## The API (`src/api.js`, given; do not change it) + +Everything in `BASE.md`, and three new functions: + +- `fetchLabels(id: number): Promise<string[]>`: the issue's labels, in order. May reject with an `Error`. +- `addLabel(id: number, label: string): Promise<string[]>`: adds a label at the end on the server; answers the + issue's labels after the change. May reject with an `Error` (the server declined). +- `removeLabel(id: number, label: string): Promise<string[]>`: removes a label on the server; answers the issue's + labels after the change. May reject with an `Error` (the server declined). + +## What to add + +Elements the tests look for carry these `data-testid`s. Text in quotes must appear exactly as written. + +1. **Labels.** While an issue is selected, the detail has an element `labels` with that issue's labels, from + `fetchLabels(id)`. + - While they load: `loading labels`. + - Then, per label, an element `data-testid="label"` reading the label (`bug`), and next to it a button + `data-testid="remove-label"` reading `remove <label>` (`remove bug`). + - If it fails: `labels error: <message>`. +2. **Adding.** The detail has a text input `label-input` and a button `add-label`. Clicking `add-label` while + `label-input` holds a label the issue does not have yet: + - adds the label as the last one straight away and empties the input; + - calls `addLabel(id, label)`; + - if the server declines, the label goes away, and the detail shows `label error: <message>` + (`label error: declined`); + - if the server accepts, the label stays. + - A label the issue already shows is not added again, and nothing is asked. +3. **Removing.** Clicking a label's `remove-label` button: + - removes the label straight away; + - calls `removeLabel(id, label)`; + - if the server declines, the label comes back where it was, and the detail shows `label error: <message>`; + - if the server accepts, the label stays out. +4. A label error is cleared when a new label change (an add or a remove) starts or another issue is selected. diff --git a/eval/tasks/chain1/fixtures.mjs b/eval/tasks/chain1/fixtures.mjs new file mode 100644 index 0000000..a74445a --- /dev/null +++ b/eval/tasks/chain1/fixtures.mjs @@ -0,0 +1,53 @@ +// Issues +export const OPEN = [{ id: 1, title: "Login fails", assignee: null }, { id: 3, title: "Crash on save", assignee: null }, { id: 4, title: "Typo in footer", assignee: "dana" }]; +export const CLOSED = [{ id: 2, title: "Old bug", assignee: "eve" }]; +export const OPEN_ROWS = ["#1 Login fails (unassigned)", "#3 Crash on save (unassigned)", "#4 Typo in footer (dana)"]; +export const D1 = { id: 1, title: "Login fails", body: "on Safari only" }; +export const D3 = { id: 3, title: "Crash on save", body: "stack trace attached" }; +export const C3 = [{ author: "dana", text: "seen it too" }]; +export const C1 = []; +/** the open issues loaded */ +export async function loaded(app) { await app.land("fetchIssues:open", OPEN); } +/** the open issues loaded and issue `id` selected with its detail */ +export async function selected(app, id = 3, detail = D3) { await loaded(app); await app.clickText("issue", `#${id} `); await app.land(`fetchIssue:${id}`, detail); } +/** issue 3 selected with its detail and its comments */ +export async function withComments(app) { await selected(app); await app.land("fetchComments:3", C3); } +/** type `title` into the detail's title input and click rename */ +export async function rename(app, title) { await app.type("title-input", title); await app.click("rename"); } +/** type `text` into the comment input and click post */ +export async function post(app, text) { await app.type("comment-input", text); await app.click("post"); } +export const openWithout = id => OPEN.filter(i => i.id !== id); +export const rowsWithout = id => OPEN_ROWS.filter(r => !r.startsWith(`#${id} `)); + +// Help +export const AP = [{ id: 1, name: "apple" }, { id: 2, name: "apricot" }]; +export const APP = [{ id: 1, name: "apple" }, { id: 3, name: "applesauce" }, { id: 4, name: "apple pie" }]; +export const ITEM3 = { id: 3, name: "applesauce", blurb: "stewed apples" }; +export const ITEM4 = { id: 4, name: "apple pie", blurb: "baked with cinnamon" }; +/** type a query and land its answer */ +export async function searched(app, q, hits) { await app.type("query", q); await app.land(`search:${q}`, hits); } + +// Settings +export const ANN = { name: "Ann", theme: "light", density: "compact" }; +export async function settingsLoaded(app) { await app.land("loadSettings:", ANN); } +/** loaded, the name typed, the theme picked, then saved: returns the request's key */ +export async function saving(app, name, theme) { + await settingsLoaded(app); + await app.type("name", name); + if (theme) await app.click(`theme-${theme}`); + await app.click("save"); + return app.only("saveSettings"); +} + +/** open the help or settings tab first */ +export async function open(app, page) { await app.click(`tab-${page}`); } + +// Labels +export const L3 = ["bug", "ui"]; +export const L1 = []; +/** issue 3 selected with its detail, comments and labels */ +export async function withLabels(app) { await selected(app); await app.landIfAsked("fetchComments:3", C3); await app.land("fetchLabels:3", L3); } +/** type `label` into the label input and click add-label */ +export async function addLabel(app, label) { await app.type("label-input", label); await app.click("add-label"); } +/** click the remove button of `label` */ +export async function removeLabel(app, label) { await app.clickText("remove-label", `remove ${label}`); } diff --git a/eval/tasks/chain1/hidden-overlap.mjs b/eval/tasks/chain1/hidden-overlap.mjs new file mode 100644 index 0000000..8dead4b --- /dev/null +++ b/eval/tasks/chain1/hidden-overlap.mjs @@ -0,0 +1,5 @@ +// Round 7, chain step 1: every step's hidden-overlap tests so far, deduplicated by name. Never shown to the agent. +import s0 from "../labels/hidden-overlap.mjs"; + +const seen = new Set(); +export default [...s0].filter(t => !seen.has(t.name) && seen.add(t.name)); diff --git a/eval/tasks/chain1/hidden.mjs b/eval/tasks/chain1/hidden.mjs new file mode 100644 index 0000000..746db62 --- /dev/null +++ b/eval/tasks/chain1/hidden.mjs @@ -0,0 +1,5 @@ +// Round 7, chain step 1: every step's hidden tests so far, deduplicated by name. Never shown to the agent. +import s0 from "../labels/hidden.mjs"; + +const seen = new Set(); +export default [...s0].filter(t => !seen.has(t.name) && seen.add(t.name)); diff --git a/eval/tasks/chain1/visible.mjs b/eval/tasks/chain1/visible.mjs new file mode 100644 index 0000000..524c5b4 --- /dev/null +++ b/eval/tasks/chain1/visible.mjs @@ -0,0 +1,176 @@ +// Visible tests: the agent sees and runs these. The desk's, then each change's so far. +import { ANN, AP, APP, C1, C3, CLOSED, D1, D3, ITEM3, ITEM4, L1, L3, OPEN, OPEN_ROWS, addLabel, loaded, open, openWithout, post, removeLabel, rename, rowsWithout, saving, searched, selected, settingsLoaded, withComments, withLabels } from "./fixtures.mjs"; + +export default [ + { name: "on load: the open issues load", async run(app) { + await app.see("list", "loading open issues"); + await app.see("detail", "select an issue"); + await loaded(app); + await app.rows("issue", OPEN_ROWS); + } }, + { name: "switching the filter loads the closed issues", async run(app) { + await loaded(app); + await app.click("filter-closed"); + await app.see("list", "loading closed issues"); + await app.land("fetchIssues:closed", CLOSED); + await app.rows("issue", ["#2 Old bug (eve)"]); + } }, + { name: "selecting an issue loads its detail", async run(app) { + await loaded(app); + await app.clickText("issue", "#3 "); + await app.see("detail", "loading #3"); + await app.land("fetchIssue:3", D3); + await app.see("detail", "#3 Crash on save", "stack trace attached"); + } }, + { name: "assigning shows the new assignee at once", async run(app) { + await selected(app); + await app.type("user", "ann"); + await app.see("assign", "assign to ann"); + await app.click("assign"); + await app.inAir("assign:3,ann"); + await app.see("list", "#3 Crash on save (ann)"); + await app.land("assign:3,ann", { id: 3, assignee: "ann" }); + await app.see("list", "#3 Crash on save (ann)"); + } }, + { name: "renaming shows the new title at once in the row and the heading", async run(app) { + await selected(app); + await rename(app, "Crash at boot"); + await app.inAir("renameIssue:3,Crash at boot"); + await app.see("list", "#3 Crash at boot (unassigned)"); + await app.see("detail", "#3 Crash at boot"); + } }, + { name: "an accepted rename keeps the new title", async run(app) { + await selected(app); + await rename(app, "Crash at boot"); + await app.land("renameIssue:3,Crash at boot", { id: 3, title: "Crash at boot" }); + await app.see("list", "#3 Crash at boot (unassigned)"); + await app.see("detail", "#3 Crash at boot"); + } }, + { name: "closing removes the row at once and keeps the detail", async run(app) { + await selected(app); + await app.click("close"); + await app.inAir("closeIssue:3"); + await app.rows("issue", rowsWithout(3)); + await app.see("detail", "#3 Crash on save"); + } }, + { name: "an accepted close keeps the row out", async run(app) { + await selected(app); + await app.click("close"); + await app.land("closeIssue:3", { id: 3 }); + await app.rows("issue", rowsWithout(3)); + } }, + { name: "selecting an issue loads its comments", async run(app) { + await selected(app); + await app.see("comments", "loading comments"); + await app.land("fetchComments:3", [{ author: "dana", text: "seen it too" }]); + await app.rows("comment", ["dana: seen it too"]); + } }, + { name: "posting shows the comment at once and keeps it when accepted", async run(app) { + await withComments(app); + await app.type("user", "ann"); + await post(app, "on it"); + await app.inAir("addComment:3,ann,on it"); + await app.rows("comment", ["dana: seen it too", "ann: on it (sending…)"]); + await app.hasValue("comment-input", ""); + await app.land("addComment:3,ann,on it", { author: "ann", text: "on it" }); + await app.landIfAsked("fetchComments:3", [{ author: "dana", text: "seen it too" }, { author: "ann", text: "on it" }]); + await app.rows("comment", ["dana: seen it too", "ann: on it"]); + } }, + { name: "help on open: nothing selected, nothing asked", async run(app) { + await open(app, "help"); + await app.see("item", "pick a result"); + if ((await app.asked()).some(k => /^(search|fetchItem):/.test(k))) throw new Error(`asked ${await app.asked()} on open`); + } }, + { name: "one character is too short and asks nothing", async run(app) { + await open(app, "help"); + await app.type("query", "a"); + await app.see("results", "too short: a"); + await app.notAsked("search:a"); + } }, + { name: "a search shows searching…, then its results", async run(app) { + await open(app, "help"); + await app.type("query", "ap"); + await app.inAir("search:ap"); + await app.see("results", "searching…"); + await app.land("search:ap", AP); + await app.see("results", "2 results for ap"); + await app.notSee("results", "searching…"); + await app.rows("hit", ["apple", "apricot"]); + } }, + { name: "selecting a hit loads its detail", async run(app) { + await open(app, "help"); + await searched(app, "app", APP); + await app.clickText("hit", "applesauce"); + await app.rows("hit", ["apple", "> applesauce", "apple pie"]); + await app.see("item", "loading…"); + await app.land("fetchItem:3", ITEM3); + await app.see("item", "applesauce", "stewed apples"); + } }, + { name: "loads, then shows the saved line and the form", async run(app) { + await open(app, "settings"); + await app.see("load", "Loading settings…"); + await settingsLoaded(app); + await app.eq("saved", "Saved: Ann · light · compact"); + await app.hasValue("name", "Ann"); + await app.eq("name-count", "3/20"); + await app.eq("preview", "Preview: light / compact"); + } }, + { name: "validates the name", async run(app) { + await open(app, "settings"); + await settingsLoaded(app); + await app.type("name", ""); + await app.see("name-error", "Display name is required"); + await app.type("name", "Bartholomew the Magnificent"); + await app.see("name-error", "Display name must be at most 20 characters, not 27"); + await app.eq("name-count", "27/20"); + await app.type("name", "Bea"); + await app.notSee("name-error", "Display name"); + } }, + { name: "a save shows at once, then lands", async run(app) { + await open(app, "settings"); + await saving(app, "Bea", "dark"); + await app.eq("saved", "Saved: Bea · dark · compact (saving…)"); + await app.landOnly("saveSettings", { name: "Bea", theme: "dark", density: "compact" }); + await app.eq("saved", "Saved: Bea · dark · compact"); + } }, + { name: "a failed load says so", async run(app) { + await open(app, "settings"); + await app.fail("loadSettings:", "network down"); + await app.see("load", "Could not load settings: network down"); + } }, + { name: "tabs: settings, then back to issues, starts the issues page again", async run(app) { + await selected(app); + await open(app, "settings"); + await app.see("load", "Loading settings…"); + await app.notSee("list", "Crash on save"); + await open(app, "issues"); + await app.see("list", "loading open issues"); + await app.see("detail", "select an issue"); + } }, + { name: "selecting an issue loads its labels", async run(app) { + await selected(app); + await app.see("labels", "loading labels"); + await app.landIfAsked("fetchComments:3", C3); + await app.land("fetchLabels:3", L3); + await app.rows("label", ["bug", "ui"]); + } }, + { name: "adding a label shows it at once and keeps it when accepted", async run(app) { + await withLabels(app); + await addLabel(app, "crash"); + await app.inAir("addLabel:3,crash"); + await app.rows("label", ["bug", "ui", "crash"]); + await app.hasValue("label-input", ""); + await app.land("addLabel:3,crash", [...L3, "crash"]); + await app.landIfAsked("fetchLabels:3", [...L3, "crash"]); + await app.rows("label", ["bug", "ui", "crash"]); + } }, + { name: "removing a label takes it out at once", async run(app) { + await withLabels(app); + await removeLabel(app, "bug"); + await app.inAir("removeLabel:3,bug"); + await app.rows("label", ["ui"]); + await app.land("removeLabel:3,bug", ["ui"]); + await app.landIfAsked("fetchLabels:3", ["ui"]); + await app.rows("label", ["ui"]); + } }, +]; diff --git a/eval/tasks/chain2/BASE.md b/eval/tasks/chain2/BASE.md new file mode 100644 index 0000000..87294cd --- /dev/null +++ b/eval/tasks/chain2/BASE.md @@ -0,0 +1,187 @@ +The app was built to the brief below, then changed by the change request after it, in order. + +# Issue desk + +Build an issue desk: three pages behind a row of tabs. The issues page is an issue tracker with assigning, renaming, +closing and comments; the help page searches a help catalogue; the settings page edits the user's settings. + +## The API (`src/api.js`, given; do not change it) + +Issues: + +- `fetchIssues(filter: "open" | "closed"): Promise<{ id: number; title: string; assignee: string | null }[]>`. May + reject with an `Error`. +- `fetchIssue(id: number): Promise<{ id: number; title: string; body: string } | null>`: `null` when there is no such + issue. May reject with an `Error`. +- `assign(id: number, user: string): Promise<{ id: number; assignee: string }>`: assigns the issue on the server. May + reject with an `Error` (the server declined). +- `renameIssue(id: number, title: string): Promise<{ id: number; title: string }>`: renames the issue on the server. + May reject with an `Error` (the server declined). +- `closeIssue(id: number): Promise<{ id: number }>`: closes the issue on the server. May reject with an `Error` (the + server declined). +- `fetchComments(id: number): Promise<{ author: string; text: string }[]>`: the issue's comments, oldest first. May + reject with an `Error`. +- `addComment(id: number, author: string, text: string): Promise<{ author: string; text: string }>`: adds a comment + on the server. May reject with an `Error` (the server declined). + +Help: + +- `search(q: string): Promise<{ id: number; name: string }[]>`: the hits for `q`, in order. It may reject with an + `Error`. +- `fetchItem(id: number): Promise<{ id: number; name: string; blurb: string } | null>`: one item, or `null` when there + is no item with that id. It may reject with an `Error`. + +Settings: + +- `loadSettings(): Promise<{ name: string; theme: "light" | "dark"; density: "comfortable" | "compact" }>`. May + reject with an `Error`. +- `saveSettings(s: { name: string; theme: "light" | "dark"; density: "comfortable" | "compact" }): Promise<same shape>`: + answers the settings as saved. May reject with an `Error`. + +## What the app must do + +Elements the tests look for carry these `data-testid`s. Text in quotes must appear exactly as written. + +### Tabs + +1. Three buttons, `tab-issues`, `tab-help` and `tab-settings`, always on screen. The app opens on the issues page. +2. Only the open page is on screen. Leaving a page discards it: opening it again starts it as it starts when the app + opens (it loads again, nothing selected, inputs at their starting values). A request a discarded page made may + still answer; it must not show on, or change, any page. + +### The issues page + +1. **Current user.** A text input, `user`, holding the current user's name. It starts as `guest`. +2. **Filter.** Two buttons, `filter-open` and `filter-closed`. The filter starts as `open`. +3. **List**, `list`, of the issues for the current filter, from `fetchIssues(filter)`. + - While they load (when the page opens or the filter changes): `loading <filter> issues` (`loading closed issues`). + - Then one row per issue, `data-testid="issue"`, reading `#<id> <title> (<assignee>)`, or `(unassigned)` when the + assignee is `null`: `#3 Crash on save (unassigned)`. + - If it fails: `list error: <message>`. +4. **Detail**, `detail`. + - Nothing selected: `select an issue`. Clicking a row selects its issue. + - The selected issue comes from `fetchIssue(id)`. While it loads: `loading #<id>`. + - An issue: `#<id> <title>` and its body. + - `null`: `detail error: issue #<id> not found`. A failure: `detail error: <message>`. + - A button `retry` (`data-testid="retry"`) calls `fetchIssue(id)` again and shows its answer. +5. **Assign.** While an issue is selected, the detail has a button `assign` (`data-testid="assign"`) reading + `assign to <current user>`. Clicking it: + - shows the current user as the issue's assignee in its list row straight away; + - calls `assign(id, user)`; + - if the server declines, the row goes back to the assignee it had before, and the detail shows the server's + message (`declined`); + - if the server accepts, the row keeps the new assignee. + - An assignment error is cleared when a new assignment starts or another issue is selected. +6. **Rename.** While an issue is selected, the detail has a text input `title-input`, empty when the issue is + selected, and a button `rename` (`data-testid="rename"`). Clicking `rename` while `title-input` holds a title: + - shows the new title in the issue's list row and in the detail's heading (`#<id> <title>`) straight away; + - calls `renameIssue(id, title)`; + - if the server declines, the row and the heading go back to the title they had before, and the detail shows + `rename error: <message>` (`rename error: declined`); + - if the server accepts, the row and the heading keep the new title. + - A rename error is cleared when a new rename starts or another issue is selected. +7. **Close.** While an issue is selected, the detail has a button `close` (`data-testid="close"`). Clicking it: + - removes the issue's row from the list of open issues straight away (the detail keeps showing the issue); + - calls `closeIssue(id)`; + - if the server declines, the row comes back where it was, and the detail shows `close error: <message>` + (`close error: declined`); + - if the server accepts, the row stays out of the open issues. + - A close error is cleared when a new close starts or another issue is selected. +8. **Comments.** While an issue is selected, the detail has an element `comments` with that issue's comments, from + `fetchComments(id)`. + - While they load: `loading comments`. + - Then one row per comment, `data-testid="comment"`, reading `<author>: <text>` (`dana: seen it too`). + - If it fails: `comments error: <message>`. +9. **Posting.** The detail has a text input `comment-input` and a button `post` (`data-testid="post"`). Clicking + `post` while `comment-input` holds text: + - adds the comment as the last row straight away, reading `<current user>: <text> (sending…)`, and empties the + input; + - calls `addComment(id, user, text)`; + - if the server declines, the row goes away, and the detail shows `post error: <message>` (`post error: declined`); + - if the server accepts, the row stays and reads `<current user>: <text>`. + - A post error is cleared when a new post starts or another issue is selected. + +### The help page + +1. A text input, `query`. What the user types is the query. +2. A results area, `results`. + - Empty query: the results area shows nothing, and nothing is asked. + - A query of one character: the results area shows `too short: <q>` (for `a`: `too short: a`). Nothing is asked. + - Two characters or more: call `search(q)` and show `searching…` until the results arrive. Results already on + screen stay there until the new ones replace them. + - The results: a line `<n> results for <q>` (`2 results for ap`) and one list item per hit, `data-testid="hit"`, + whose text is the hit's name. + - If the search fails: `search failed: <message>`. +3. Clicking a hit selects it. The selected hit's text starts with `> ` (`> apple pie`); the others have no prefix. +4. A help pane, `item`. + - Nothing selected: `pick a result`. + - When a hit is selected, call `fetchItem(id)` and show `loading…` until it answers. + - The answer is an item: the pane shows its name and its blurb. + - The answer is `null`: `item <id> not found`. + - It fails: `failed: <message>` and a button `retry-item` (`data-testid="retry-item"`) that calls `fetchItem(id)` + again and shows its answer. + +### The settings page + +1. **Loading.** Call `loadSettings()` when the page opens. Until it answers, an element `load` shows + `Loading settings…`. If it fails, `load` shows `Could not load settings: <message>` and a button `retry-load` + calls `loadSettings()` again and shows the settings it answers. +2. **Saved line**, `saved`, once the settings have loaded: `Saved: <name> · <theme> · <density>` + (`Saved: Ann · light · compact`). +3. **The draft.** Once loaded, a form is filled from the loaded settings and then belongs to the user: + - an input `name` (the display name), a counter `name-count` reading `<length>/20`; + - `name-error`, shown only when the name is invalid: `Display name is required` when empty, + `Display name must be at most 20 characters, not <length>` when longer than 20; + - buttons `theme-light` and `theme-dark` that set the draft's theme; a button `density-toggle` that switches the + draft's density between `comfortable` and `compact`; + - `preview` reading `Preview: <draft theme> / <draft density>`. +4. **Save**, a button `save`. + - With an invalid name it does nothing (nothing is asked). + - Otherwise it calls `saveSettings(draft)`. Straight away the saved line shows the values being saved followed by + ` (saving…)` (`Saved: Bea · dark · compact (saving…)`), and any previous save error disappears. + - When the save succeeds, the saved line shows the saved settings, without `(saving…)`. + - When it fails, the saved line goes back to what was saved before, without `(saving…)`, and `save-error` shows + `Save failed: <message>`. + - Saving never changes the draft. + +--- + +## Change 1: labels + +The app in `src/` is an issue desk that already works. `BASE.md` is the brief it was built to. Keep everything it +does, and add labels to the selected issue. + +### The API (`src/api.js`, given; do not change it) + +Everything in `BASE.md`, and three new functions: + +- `fetchLabels(id: number): Promise<string[]>`: the issue's labels, in order. May reject with an `Error`. +- `addLabel(id: number, label: string): Promise<string[]>`: adds a label at the end on the server; answers the + issue's labels after the change. May reject with an `Error` (the server declined). +- `removeLabel(id: number, label: string): Promise<string[]>`: removes a label on the server; answers the issue's + labels after the change. May reject with an `Error` (the server declined). + +### What to add + +Elements the tests look for carry these `data-testid`s. Text in quotes must appear exactly as written. + +1. **Labels.** While an issue is selected, the detail has an element `labels` with that issue's labels, from + `fetchLabels(id)`. + - While they load: `loading labels`. + - Then, per label, an element `data-testid="label"` reading the label (`bug`), and next to it a button + `data-testid="remove-label"` reading `remove <label>` (`remove bug`). + - If it fails: `labels error: <message>`. +2. **Adding.** The detail has a text input `label-input` and a button `add-label`. Clicking `add-label` while + `label-input` holds a label the issue does not have yet: + - adds the label as the last one straight away and empties the input; + - calls `addLabel(id, label)`; + - if the server declines, the label goes away, and the detail shows `label error: <message>` + (`label error: declined`); + - if the server accepts, the label stays. + - A label the issue already shows is not added again, and nothing is asked. +3. **Removing.** Clicking a label's `remove-label` button: + - removes the label straight away; + - calls `removeLabel(id, label)`; + - if the server declines, the label comes back where it was, and the detail shows `label error: <message>`; + - if the server accepts, the label stays out. +4. A label error is cleared when a new label change (an add or a remove) starts or another issue is selected. diff --git a/eval/tasks/chain2/api.d.rx b/eval/tasks/chain2/api.d.rx new file mode 100644 index 0000000..f66befd --- /dev/null +++ b/eval/tasks/chain2/api.d.rx @@ -0,0 +1,23 @@ +// src/api.js, as .rx sees it. fetchIssues, fetchIssue, fetchComments, fetchLabels, search, fetchItem and loadSettings +// are loaders (safe to ask from an equation); assign, renameIssue, closeIssue, addComment, addLabel, removeLabel and +// saveSettings write. +export type Issue = { id: number; title: string; assignee: string | null } +export type IssueDetail = { id: number; title: string; body: string } +export pure fn fetchIssues(filter: string): Promise<Issue[]> +export pure fn fetchIssue(id: number): Promise<IssueDetail | null> +export fn assign(id: number, user: string): Promise<{ id: number; assignee: string }> +export fn renameIssue(id: number, title: string): Promise<{ id: number; title: string }> +export fn closeIssue(id: number): Promise<{ id: number }> +export type Comment = { author: string; text: string } +export pure fn fetchComments(id: number): Promise<Comment[]> +export fn addComment(id: number, author: string, text: string): Promise<Comment> +export type Hit = { id: number; name: string } +export type Item = { id: number; name: string; blurb: string } +export pure fn search(q: string): Promise<Hit[]> +export pure fn fetchItem(id: number): Promise<Item | null> +export type Settings = { name: string; theme: string; density: string } +export pure fn loadSettings(): Promise<Settings> +export fn saveSettings(s: Settings): Promise<Settings> +export pure fn fetchLabels(id: number): Promise<string[]> +export fn addLabel(id: number, label: string): Promise<string[]> +export fn removeLabel(id: number, label: string): Promise<string[]> diff --git a/eval/tasks/chain2/api.d.ts b/eval/tasks/chain2/api.d.ts new file mode 100644 index 0000000..0acb5a4 --- /dev/null +++ b/eval/tasks/chain2/api.d.ts @@ -0,0 +1,22 @@ +export type Issue = { id: number; title: string; assignee: string | null }; +export type IssueDetail = { id: number; title: string; body: string }; +export function fetchIssues(filter: "open" | "closed"): Promise<Issue[]>; +export function fetchIssue(id: number): Promise<IssueDetail | null>; +export function assign(id: number, user: string): Promise<{ id: number; assignee: string }>; +export function renameIssue(id: number, title: string): Promise<{ id: number; title: string }>; +export function closeIssue(id: number): Promise<{ id: number }>; +export type Comment = { author: string; text: string }; +export function fetchComments(id: number): Promise<Comment[]>; +export function addComment(id: number, author: string, text: string): Promise<Comment>; +export type Hit = { id: number; name: string }; +export type Item = { id: number; name: string; blurb: string }; +export function search(q: string): Promise<Hit[]>; +export function fetchItem(id: number): Promise<Item | null>; +export type Theme = "light" | "dark"; +export type Density = "comfortable" | "compact"; +export type Settings = { name: string; theme: Theme; density: Density }; +export function loadSettings(): Promise<Settings>; +export function saveSettings(s: Settings): Promise<Settings>; +export function fetchLabels(id: number): Promise<string[]>; +export function addLabel(id: number, label: string): Promise<string[]>; +export function removeLabel(id: number, label: string): Promise<string[]>; diff --git a/eval/tasks/chain2/api.js b/eval/tasks/chain2/api.js new file mode 100644 index 0000000..ac4c81c --- /dev/null +++ b/eval/tasks/chain2/api.js @@ -0,0 +1,16 @@ +import { flight } from "./flights.js"; + +export const fetchIssues = filter => flight("fetchIssues", [filter]); +export const fetchIssue = id => flight("fetchIssue", [id]); +export const assign = (id, user) => flight("assign", [id, user]); +export const renameIssue = (id, title) => flight("renameIssue", [id, title]); +export const closeIssue = id => flight("closeIssue", [id]); +export const fetchComments = id => flight("fetchComments", [id]); +export const addComment = (id, author, text) => flight("addComment", [id, author, text]); +export const search = q => flight("search", [q]); +export const fetchItem = id => flight("fetchItem", [id]); +export const loadSettings = () => flight("loadSettings", []); +export const saveSettings = s => flight("saveSettings", [s]); +export const fetchLabels = id => flight("fetchLabels", [id]); +export const addLabel = (id, label) => flight("addLabel", [id, label]); +export const removeLabel = (id, label) => flight("removeLabel", [id, label]); diff --git a/eval/tasks/chain2/brief-plain.md b/eval/tasks/chain2/brief-plain.md new file mode 100644 index 0000000..edca84c --- /dev/null +++ b/eval/tasks/chain2/brief-plain.md @@ -0,0 +1,21 @@ +# Change request: open-issue count + +The app in `src/` is an issue desk that already works. `BASE.md` is the brief it was built to, with the changes made since. Keep everything it +does, and show how many issues are open on the issues tab. + +## The API (`src/api.js`, given; do not change it) + +Everything in `BASE.md`. Nothing new. + +## What to add + +Elements the tests look for carry these `data-testid`s. Text in quotes must appear exactly as written. + +1. The `tab-issues` button reads `Issues (<n>)`, where `<n>` is the number of rows in the list of open issues + (`Issues (3)`). It shows on every page. Until the open issues have loaded once, it reads `Issues`. +2. The count follows the list of open issues as the issues page shows it: whenever the open issues load again (the + issues page opening again, or its filter switching back to open), the count follows the new answer. While the + list shows closed issues, is loading, or failed, and while another page is open, the count keeps the last number + it showed. +3. **Closing.** Clicking `close` on an open issue takes one off the count straight away, as its row leaves the + list. If the server declines, the count goes back; if it accepts, the count stays down. diff --git a/eval/tasks/chain2/fixtures.mjs b/eval/tasks/chain2/fixtures.mjs new file mode 100644 index 0000000..a74445a --- /dev/null +++ b/eval/tasks/chain2/fixtures.mjs @@ -0,0 +1,53 @@ +// Issues +export const OPEN = [{ id: 1, title: "Login fails", assignee: null }, { id: 3, title: "Crash on save", assignee: null }, { id: 4, title: "Typo in footer", assignee: "dana" }]; +export const CLOSED = [{ id: 2, title: "Old bug", assignee: "eve" }]; +export const OPEN_ROWS = ["#1 Login fails (unassigned)", "#3 Crash on save (unassigned)", "#4 Typo in footer (dana)"]; +export const D1 = { id: 1, title: "Login fails", body: "on Safari only" }; +export const D3 = { id: 3, title: "Crash on save", body: "stack trace attached" }; +export const C3 = [{ author: "dana", text: "seen it too" }]; +export const C1 = []; +/** the open issues loaded */ +export async function loaded(app) { await app.land("fetchIssues:open", OPEN); } +/** the open issues loaded and issue `id` selected with its detail */ +export async function selected(app, id = 3, detail = D3) { await loaded(app); await app.clickText("issue", `#${id} `); await app.land(`fetchIssue:${id}`, detail); } +/** issue 3 selected with its detail and its comments */ +export async function withComments(app) { await selected(app); await app.land("fetchComments:3", C3); } +/** type `title` into the detail's title input and click rename */ +export async function rename(app, title) { await app.type("title-input", title); await app.click("rename"); } +/** type `text` into the comment input and click post */ +export async function post(app, text) { await app.type("comment-input", text); await app.click("post"); } +export const openWithout = id => OPEN.filter(i => i.id !== id); +export const rowsWithout = id => OPEN_ROWS.filter(r => !r.startsWith(`#${id} `)); + +// Help +export const AP = [{ id: 1, name: "apple" }, { id: 2, name: "apricot" }]; +export const APP = [{ id: 1, name: "apple" }, { id: 3, name: "applesauce" }, { id: 4, name: "apple pie" }]; +export const ITEM3 = { id: 3, name: "applesauce", blurb: "stewed apples" }; +export const ITEM4 = { id: 4, name: "apple pie", blurb: "baked with cinnamon" }; +/** type a query and land its answer */ +export async function searched(app, q, hits) { await app.type("query", q); await app.land(`search:${q}`, hits); } + +// Settings +export const ANN = { name: "Ann", theme: "light", density: "compact" }; +export async function settingsLoaded(app) { await app.land("loadSettings:", ANN); } +/** loaded, the name typed, the theme picked, then saved: returns the request's key */ +export async function saving(app, name, theme) { + await settingsLoaded(app); + await app.type("name", name); + if (theme) await app.click(`theme-${theme}`); + await app.click("save"); + return app.only("saveSettings"); +} + +/** open the help or settings tab first */ +export async function open(app, page) { await app.click(`tab-${page}`); } + +// Labels +export const L3 = ["bug", "ui"]; +export const L1 = []; +/** issue 3 selected with its detail, comments and labels */ +export async function withLabels(app) { await selected(app); await app.landIfAsked("fetchComments:3", C3); await app.land("fetchLabels:3", L3); } +/** type `label` into the label input and click add-label */ +export async function addLabel(app, label) { await app.type("label-input", label); await app.click("add-label"); } +/** click the remove button of `label` */ +export async function removeLabel(app, label) { await app.clickText("remove-label", `remove ${label}`); } diff --git a/eval/tasks/chain2/hidden-overlap.mjs b/eval/tasks/chain2/hidden-overlap.mjs new file mode 100644 index 0000000..a0a0c45 --- /dev/null +++ b/eval/tasks/chain2/hidden-overlap.mjs @@ -0,0 +1,6 @@ +// Round 7, chain step 2: every step's hidden-overlap tests so far, deduplicated by name. Never shown to the agent. +import s0 from "../labels/hidden-overlap.mjs"; +import s1 from "../count/hidden-overlap.mjs"; + +const seen = new Set(); +export default [...s0, ...s1].filter(t => !seen.has(t.name) && seen.add(t.name)); diff --git a/eval/tasks/chain2/hidden.mjs b/eval/tasks/chain2/hidden.mjs new file mode 100644 index 0000000..0f949f4 --- /dev/null +++ b/eval/tasks/chain2/hidden.mjs @@ -0,0 +1,6 @@ +// Round 7, chain step 2: every step's hidden tests so far, deduplicated by name. Never shown to the agent. +import s0 from "../labels/hidden.mjs"; +import s1 from "../count/hidden.mjs"; + +const seen = new Set(); +export default [...s0, ...s1].filter(t => !seen.has(t.name) && seen.add(t.name)); diff --git a/eval/tasks/chain2/visible.mjs b/eval/tasks/chain2/visible.mjs new file mode 100644 index 0000000..a1d86eb --- /dev/null +++ b/eval/tasks/chain2/visible.mjs @@ -0,0 +1,189 @@ +// Visible tests: the agent sees and runs these. The desk's, then each change's so far. +import { ANN, AP, APP, C1, C3, CLOSED, D1, D3, ITEM3, ITEM4, L1, L3, OPEN, OPEN_ROWS, addLabel, loaded, open, openWithout, post, removeLabel, rename, rowsWithout, saving, searched, selected, settingsLoaded, withComments, withLabels } from "./fixtures.mjs"; + +export default [ + { name: "on load: the open issues load", async run(app) { + await app.see("list", "loading open issues"); + await app.see("detail", "select an issue"); + await loaded(app); + await app.rows("issue", OPEN_ROWS); + } }, + { name: "switching the filter loads the closed issues", async run(app) { + await loaded(app); + await app.click("filter-closed"); + await app.see("list", "loading closed issues"); + await app.land("fetchIssues:closed", CLOSED); + await app.rows("issue", ["#2 Old bug (eve)"]); + } }, + { name: "selecting an issue loads its detail", async run(app) { + await loaded(app); + await app.clickText("issue", "#3 "); + await app.see("detail", "loading #3"); + await app.land("fetchIssue:3", D3); + await app.see("detail", "#3 Crash on save", "stack trace attached"); + } }, + { name: "assigning shows the new assignee at once", async run(app) { + await selected(app); + await app.type("user", "ann"); + await app.see("assign", "assign to ann"); + await app.click("assign"); + await app.inAir("assign:3,ann"); + await app.see("list", "#3 Crash on save (ann)"); + await app.land("assign:3,ann", { id: 3, assignee: "ann" }); + await app.see("list", "#3 Crash on save (ann)"); + } }, + { name: "renaming shows the new title at once in the row and the heading", async run(app) { + await selected(app); + await rename(app, "Crash at boot"); + await app.inAir("renameIssue:3,Crash at boot"); + await app.see("list", "#3 Crash at boot (unassigned)"); + await app.see("detail", "#3 Crash at boot"); + } }, + { name: "an accepted rename keeps the new title", async run(app) { + await selected(app); + await rename(app, "Crash at boot"); + await app.land("renameIssue:3,Crash at boot", { id: 3, title: "Crash at boot" }); + await app.see("list", "#3 Crash at boot (unassigned)"); + await app.see("detail", "#3 Crash at boot"); + } }, + { name: "closing removes the row at once and keeps the detail", async run(app) { + await selected(app); + await app.click("close"); + await app.inAir("closeIssue:3"); + await app.rows("issue", rowsWithout(3)); + await app.see("detail", "#3 Crash on save"); + } }, + { name: "an accepted close keeps the row out", async run(app) { + await selected(app); + await app.click("close"); + await app.land("closeIssue:3", { id: 3 }); + await app.rows("issue", rowsWithout(3)); + } }, + { name: "selecting an issue loads its comments", async run(app) { + await selected(app); + await app.see("comments", "loading comments"); + await app.land("fetchComments:3", [{ author: "dana", text: "seen it too" }]); + await app.rows("comment", ["dana: seen it too"]); + } }, + { name: "posting shows the comment at once and keeps it when accepted", async run(app) { + await withComments(app); + await app.type("user", "ann"); + await post(app, "on it"); + await app.inAir("addComment:3,ann,on it"); + await app.rows("comment", ["dana: seen it too", "ann: on it (sending…)"]); + await app.hasValue("comment-input", ""); + await app.land("addComment:3,ann,on it", { author: "ann", text: "on it" }); + await app.landIfAsked("fetchComments:3", [{ author: "dana", text: "seen it too" }, { author: "ann", text: "on it" }]); + await app.rows("comment", ["dana: seen it too", "ann: on it"]); + } }, + { name: "help on open: nothing selected, nothing asked", async run(app) { + await open(app, "help"); + await app.see("item", "pick a result"); + if ((await app.asked()).some(k => /^(search|fetchItem):/.test(k))) throw new Error(`asked ${await app.asked()} on open`); + } }, + { name: "one character is too short and asks nothing", async run(app) { + await open(app, "help"); + await app.type("query", "a"); + await app.see("results", "too short: a"); + await app.notAsked("search:a"); + } }, + { name: "a search shows searching…, then its results", async run(app) { + await open(app, "help"); + await app.type("query", "ap"); + await app.inAir("search:ap"); + await app.see("results", "searching…"); + await app.land("search:ap", AP); + await app.see("results", "2 results for ap"); + await app.notSee("results", "searching…"); + await app.rows("hit", ["apple", "apricot"]); + } }, + { name: "selecting a hit loads its detail", async run(app) { + await open(app, "help"); + await searched(app, "app", APP); + await app.clickText("hit", "applesauce"); + await app.rows("hit", ["apple", "> applesauce", "apple pie"]); + await app.see("item", "loading…"); + await app.land("fetchItem:3", ITEM3); + await app.see("item", "applesauce", "stewed apples"); + } }, + { name: "loads, then shows the saved line and the form", async run(app) { + await open(app, "settings"); + await app.see("load", "Loading settings…"); + await settingsLoaded(app); + await app.eq("saved", "Saved: Ann · light · compact"); + await app.hasValue("name", "Ann"); + await app.eq("name-count", "3/20"); + await app.eq("preview", "Preview: light / compact"); + } }, + { name: "validates the name", async run(app) { + await open(app, "settings"); + await settingsLoaded(app); + await app.type("name", ""); + await app.see("name-error", "Display name is required"); + await app.type("name", "Bartholomew the Magnificent"); + await app.see("name-error", "Display name must be at most 20 characters, not 27"); + await app.eq("name-count", "27/20"); + await app.type("name", "Bea"); + await app.notSee("name-error", "Display name"); + } }, + { name: "a save shows at once, then lands", async run(app) { + await open(app, "settings"); + await saving(app, "Bea", "dark"); + await app.eq("saved", "Saved: Bea · dark · compact (saving…)"); + await app.landOnly("saveSettings", { name: "Bea", theme: "dark", density: "compact" }); + await app.eq("saved", "Saved: Bea · dark · compact"); + } }, + { name: "a failed load says so", async run(app) { + await open(app, "settings"); + await app.fail("loadSettings:", "network down"); + await app.see("load", "Could not load settings: network down"); + } }, + { name: "tabs: settings, then back to issues, starts the issues page again", async run(app) { + await selected(app); + await open(app, "settings"); + await app.see("load", "Loading settings…"); + await app.notSee("list", "Crash on save"); + await open(app, "issues"); + await app.see("list", "loading open issues"); + await app.see("detail", "select an issue"); + } }, + { name: "selecting an issue loads its labels", async run(app) { + await selected(app); + await app.see("labels", "loading labels"); + await app.landIfAsked("fetchComments:3", C3); + await app.land("fetchLabels:3", L3); + await app.rows("label", ["bug", "ui"]); + } }, + { name: "adding a label shows it at once and keeps it when accepted", async run(app) { + await withLabels(app); + await addLabel(app, "crash"); + await app.inAir("addLabel:3,crash"); + await app.rows("label", ["bug", "ui", "crash"]); + await app.hasValue("label-input", ""); + await app.land("addLabel:3,crash", [...L3, "crash"]); + await app.landIfAsked("fetchLabels:3", [...L3, "crash"]); + await app.rows("label", ["bug", "ui", "crash"]); + } }, + { name: "removing a label takes it out at once", async run(app) { + await withLabels(app); + await removeLabel(app, "bug"); + await app.inAir("removeLabel:3,bug"); + await app.rows("label", ["ui"]); + await app.land("removeLabel:3,bug", ["ui"]); + await app.landIfAsked("fetchLabels:3", ["ui"]); + await app.rows("label", ["ui"]); + } }, + { name: "the issues tab shows the open count once the open issues load", async run(app) { + await app.eq("tab-issues", "Issues"); + await loaded(app); + await app.eq("tab-issues", "Issues (3)"); + } }, + { name: "closing takes one off the count at once and keeps it off when accepted", async run(app) { + await selected(app); + await app.click("close"); + await app.eq("tab-issues", "Issues (2)"); + await app.land("closeIssue:3", { id: 3 }); + await app.landIfAsked("fetchIssues:open", openWithout(3)); + await app.eq("tab-issues", "Issues (2)"); + } }, +]; diff --git a/eval/tasks/chain3/BASE.md b/eval/tasks/chain3/BASE.md new file mode 100644 index 0000000..c1f1405 --- /dev/null +++ b/eval/tasks/chain3/BASE.md @@ -0,0 +1,211 @@ +The app was built to the brief below, then changed by the 2 change requests after it, in order. + +# Issue desk + +Build an issue desk: three pages behind a row of tabs. The issues page is an issue tracker with assigning, renaming, +closing and comments; the help page searches a help catalogue; the settings page edits the user's settings. + +## The API (`src/api.js`, given; do not change it) + +Issues: + +- `fetchIssues(filter: "open" | "closed"): Promise<{ id: number; title: string; assignee: string | null }[]>`. May + reject with an `Error`. +- `fetchIssue(id: number): Promise<{ id: number; title: string; body: string } | null>`: `null` when there is no such + issue. May reject with an `Error`. +- `assign(id: number, user: string): Promise<{ id: number; assignee: string }>`: assigns the issue on the server. May + reject with an `Error` (the server declined). +- `renameIssue(id: number, title: string): Promise<{ id: number; title: string }>`: renames the issue on the server. + May reject with an `Error` (the server declined). +- `closeIssue(id: number): Promise<{ id: number }>`: closes the issue on the server. May reject with an `Error` (the + server declined). +- `fetchComments(id: number): Promise<{ author: string; text: string }[]>`: the issue's comments, oldest first. May + reject with an `Error`. +- `addComment(id: number, author: string, text: string): Promise<{ author: string; text: string }>`: adds a comment + on the server. May reject with an `Error` (the server declined). + +Help: + +- `search(q: string): Promise<{ id: number; name: string }[]>`: the hits for `q`, in order. It may reject with an + `Error`. +- `fetchItem(id: number): Promise<{ id: number; name: string; blurb: string } | null>`: one item, or `null` when there + is no item with that id. It may reject with an `Error`. + +Settings: + +- `loadSettings(): Promise<{ name: string; theme: "light" | "dark"; density: "comfortable" | "compact" }>`. May + reject with an `Error`. +- `saveSettings(s: { name: string; theme: "light" | "dark"; density: "comfortable" | "compact" }): Promise<same shape>`: + answers the settings as saved. May reject with an `Error`. + +## What the app must do + +Elements the tests look for carry these `data-testid`s. Text in quotes must appear exactly as written. + +### Tabs + +1. Three buttons, `tab-issues`, `tab-help` and `tab-settings`, always on screen. The app opens on the issues page. +2. Only the open page is on screen. Leaving a page discards it: opening it again starts it as it starts when the app + opens (it loads again, nothing selected, inputs at their starting values). A request a discarded page made may + still answer; it must not show on, or change, any page. + +### The issues page + +1. **Current user.** A text input, `user`, holding the current user's name. It starts as `guest`. +2. **Filter.** Two buttons, `filter-open` and `filter-closed`. The filter starts as `open`. +3. **List**, `list`, of the issues for the current filter, from `fetchIssues(filter)`. + - While they load (when the page opens or the filter changes): `loading <filter> issues` (`loading closed issues`). + - Then one row per issue, `data-testid="issue"`, reading `#<id> <title> (<assignee>)`, or `(unassigned)` when the + assignee is `null`: `#3 Crash on save (unassigned)`. + - If it fails: `list error: <message>`. +4. **Detail**, `detail`. + - Nothing selected: `select an issue`. Clicking a row selects its issue. + - The selected issue comes from `fetchIssue(id)`. While it loads: `loading #<id>`. + - An issue: `#<id> <title>` and its body. + - `null`: `detail error: issue #<id> not found`. A failure: `detail error: <message>`. + - A button `retry` (`data-testid="retry"`) calls `fetchIssue(id)` again and shows its answer. +5. **Assign.** While an issue is selected, the detail has a button `assign` (`data-testid="assign"`) reading + `assign to <current user>`. Clicking it: + - shows the current user as the issue's assignee in its list row straight away; + - calls `assign(id, user)`; + - if the server declines, the row goes back to the assignee it had before, and the detail shows the server's + message (`declined`); + - if the server accepts, the row keeps the new assignee. + - An assignment error is cleared when a new assignment starts or another issue is selected. +6. **Rename.** While an issue is selected, the detail has a text input `title-input`, empty when the issue is + selected, and a button `rename` (`data-testid="rename"`). Clicking `rename` while `title-input` holds a title: + - shows the new title in the issue's list row and in the detail's heading (`#<id> <title>`) straight away; + - calls `renameIssue(id, title)`; + - if the server declines, the row and the heading go back to the title they had before, and the detail shows + `rename error: <message>` (`rename error: declined`); + - if the server accepts, the row and the heading keep the new title. + - A rename error is cleared when a new rename starts or another issue is selected. +7. **Close.** While an issue is selected, the detail has a button `close` (`data-testid="close"`). Clicking it: + - removes the issue's row from the list of open issues straight away (the detail keeps showing the issue); + - calls `closeIssue(id)`; + - if the server declines, the row comes back where it was, and the detail shows `close error: <message>` + (`close error: declined`); + - if the server accepts, the row stays out of the open issues. + - A close error is cleared when a new close starts or another issue is selected. +8. **Comments.** While an issue is selected, the detail has an element `comments` with that issue's comments, from + `fetchComments(id)`. + - While they load: `loading comments`. + - Then one row per comment, `data-testid="comment"`, reading `<author>: <text>` (`dana: seen it too`). + - If it fails: `comments error: <message>`. +9. **Posting.** The detail has a text input `comment-input` and a button `post` (`data-testid="post"`). Clicking + `post` while `comment-input` holds text: + - adds the comment as the last row straight away, reading `<current user>: <text> (sending…)`, and empties the + input; + - calls `addComment(id, user, text)`; + - if the server declines, the row goes away, and the detail shows `post error: <message>` (`post error: declined`); + - if the server accepts, the row stays and reads `<current user>: <text>`. + - A post error is cleared when a new post starts or another issue is selected. + +### The help page + +1. A text input, `query`. What the user types is the query. +2. A results area, `results`. + - Empty query: the results area shows nothing, and nothing is asked. + - A query of one character: the results area shows `too short: <q>` (for `a`: `too short: a`). Nothing is asked. + - Two characters or more: call `search(q)` and show `searching…` until the results arrive. Results already on + screen stay there until the new ones replace them. + - The results: a line `<n> results for <q>` (`2 results for ap`) and one list item per hit, `data-testid="hit"`, + whose text is the hit's name. + - If the search fails: `search failed: <message>`. +3. Clicking a hit selects it. The selected hit's text starts with `> ` (`> apple pie`); the others have no prefix. +4. A help pane, `item`. + - Nothing selected: `pick a result`. + - When a hit is selected, call `fetchItem(id)` and show `loading…` until it answers. + - The answer is an item: the pane shows its name and its blurb. + - The answer is `null`: `item <id> not found`. + - It fails: `failed: <message>` and a button `retry-item` (`data-testid="retry-item"`) that calls `fetchItem(id)` + again and shows its answer. + +### The settings page + +1. **Loading.** Call `loadSettings()` when the page opens. Until it answers, an element `load` shows + `Loading settings…`. If it fails, `load` shows `Could not load settings: <message>` and a button `retry-load` + calls `loadSettings()` again and shows the settings it answers. +2. **Saved line**, `saved`, once the settings have loaded: `Saved: <name> · <theme> · <density>` + (`Saved: Ann · light · compact`). +3. **The draft.** Once loaded, a form is filled from the loaded settings and then belongs to the user: + - an input `name` (the display name), a counter `name-count` reading `<length>/20`; + - `name-error`, shown only when the name is invalid: `Display name is required` when empty, + `Display name must be at most 20 characters, not <length>` when longer than 20; + - buttons `theme-light` and `theme-dark` that set the draft's theme; a button `density-toggle` that switches the + draft's density between `comfortable` and `compact`; + - `preview` reading `Preview: <draft theme> / <draft density>`. +4. **Save**, a button `save`. + - With an invalid name it does nothing (nothing is asked). + - Otherwise it calls `saveSettings(draft)`. Straight away the saved line shows the values being saved followed by + ` (saving…)` (`Saved: Bea · dark · compact (saving…)`), and any previous save error disappears. + - When the save succeeds, the saved line shows the saved settings, without `(saving…)`. + - When it fails, the saved line goes back to what was saved before, without `(saving…)`, and `save-error` shows + `Save failed: <message>`. + - Saving never changes the draft. + +--- + +## Change 1: labels + +The app in `src/` is an issue desk that already works. `BASE.md` is the brief it was built to. Keep everything it +does, and add labels to the selected issue. + +### The API (`src/api.js`, given; do not change it) + +Everything in `BASE.md`, and three new functions: + +- `fetchLabels(id: number): Promise<string[]>`: the issue's labels, in order. May reject with an `Error`. +- `addLabel(id: number, label: string): Promise<string[]>`: adds a label at the end on the server; answers the + issue's labels after the change. May reject with an `Error` (the server declined). +- `removeLabel(id: number, label: string): Promise<string[]>`: removes a label on the server; answers the issue's + labels after the change. May reject with an `Error` (the server declined). + +### What to add + +Elements the tests look for carry these `data-testid`s. Text in quotes must appear exactly as written. + +1. **Labels.** While an issue is selected, the detail has an element `labels` with that issue's labels, from + `fetchLabels(id)`. + - While they load: `loading labels`. + - Then, per label, an element `data-testid="label"` reading the label (`bug`), and next to it a button + `data-testid="remove-label"` reading `remove <label>` (`remove bug`). + - If it fails: `labels error: <message>`. +2. **Adding.** The detail has a text input `label-input` and a button `add-label`. Clicking `add-label` while + `label-input` holds a label the issue does not have yet: + - adds the label as the last one straight away and empties the input; + - calls `addLabel(id, label)`; + - if the server declines, the label goes away, and the detail shows `label error: <message>` + (`label error: declined`); + - if the server accepts, the label stays. + - A label the issue already shows is not added again, and nothing is asked. +3. **Removing.** Clicking a label's `remove-label` button: + - removes the label straight away; + - calls `removeLabel(id, label)`; + - if the server declines, the label comes back where it was, and the detail shows `label error: <message>`; + - if the server accepts, the label stays out. +4. A label error is cleared when a new label change (an add or a remove) starts or another issue is selected. + +--- + +## Change 2: open-issue count + +The app in `src/` is an issue desk that already works. `BASE.md` is the brief it was built to. Keep everything it +does, and show how many issues are open on the issues tab. + +### The API (`src/api.js`, given; do not change it) + +Everything in `BASE.md`. Nothing new. + +### What to add + +Elements the tests look for carry these `data-testid`s. Text in quotes must appear exactly as written. + +1. The `tab-issues` button reads `Issues (<n>)`, where `<n>` is the number of rows in the list of open issues + (`Issues (3)`). It shows on every page. Until the open issues have loaded once, it reads `Issues`. +2. The count follows the list of open issues as the issues page shows it: whenever the open issues load again (the + issues page opening again, or its filter switching back to open), the count follows the new answer. While the + list shows closed issues, is loading, or failed, and while another page is open, the count keeps the last number + it showed. +3. **Closing.** Clicking `close` on an open issue takes one off the count straight away, as its row leaves the + list. If the server declines, the count goes back; if it accepts, the count stays down. diff --git a/eval/tasks/chain3/api.d.rx b/eval/tasks/chain3/api.d.rx new file mode 100644 index 0000000..f66befd --- /dev/null +++ b/eval/tasks/chain3/api.d.rx @@ -0,0 +1,23 @@ +// src/api.js, as .rx sees it. fetchIssues, fetchIssue, fetchComments, fetchLabels, search, fetchItem and loadSettings +// are loaders (safe to ask from an equation); assign, renameIssue, closeIssue, addComment, addLabel, removeLabel and +// saveSettings write. +export type Issue = { id: number; title: string; assignee: string | null } +export type IssueDetail = { id: number; title: string; body: string } +export pure fn fetchIssues(filter: string): Promise<Issue[]> +export pure fn fetchIssue(id: number): Promise<IssueDetail | null> +export fn assign(id: number, user: string): Promise<{ id: number; assignee: string }> +export fn renameIssue(id: number, title: string): Promise<{ id: number; title: string }> +export fn closeIssue(id: number): Promise<{ id: number }> +export type Comment = { author: string; text: string } +export pure fn fetchComments(id: number): Promise<Comment[]> +export fn addComment(id: number, author: string, text: string): Promise<Comment> +export type Hit = { id: number; name: string } +export type Item = { id: number; name: string; blurb: string } +export pure fn search(q: string): Promise<Hit[]> +export pure fn fetchItem(id: number): Promise<Item | null> +export type Settings = { name: string; theme: string; density: string } +export pure fn loadSettings(): Promise<Settings> +export fn saveSettings(s: Settings): Promise<Settings> +export pure fn fetchLabels(id: number): Promise<string[]> +export fn addLabel(id: number, label: string): Promise<string[]> +export fn removeLabel(id: number, label: string): Promise<string[]> diff --git a/eval/tasks/chain3/api.d.ts b/eval/tasks/chain3/api.d.ts new file mode 100644 index 0000000..0acb5a4 --- /dev/null +++ b/eval/tasks/chain3/api.d.ts @@ -0,0 +1,22 @@ +export type Issue = { id: number; title: string; assignee: string | null }; +export type IssueDetail = { id: number; title: string; body: string }; +export function fetchIssues(filter: "open" | "closed"): Promise<Issue[]>; +export function fetchIssue(id: number): Promise<IssueDetail | null>; +export function assign(id: number, user: string): Promise<{ id: number; assignee: string }>; +export function renameIssue(id: number, title: string): Promise<{ id: number; title: string }>; +export function closeIssue(id: number): Promise<{ id: number }>; +export type Comment = { author: string; text: string }; +export function fetchComments(id: number): Promise<Comment[]>; +export function addComment(id: number, author: string, text: string): Promise<Comment>; +export type Hit = { id: number; name: string }; +export type Item = { id: number; name: string; blurb: string }; +export function search(q: string): Promise<Hit[]>; +export function fetchItem(id: number): Promise<Item | null>; +export type Theme = "light" | "dark"; +export type Density = "comfortable" | "compact"; +export type Settings = { name: string; theme: Theme; density: Density }; +export function loadSettings(): Promise<Settings>; +export function saveSettings(s: Settings): Promise<Settings>; +export function fetchLabels(id: number): Promise<string[]>; +export function addLabel(id: number, label: string): Promise<string[]>; +export function removeLabel(id: number, label: string): Promise<string[]>; diff --git a/eval/tasks/chain3/api.js b/eval/tasks/chain3/api.js new file mode 100644 index 0000000..ac4c81c --- /dev/null +++ b/eval/tasks/chain3/api.js @@ -0,0 +1,16 @@ +import { flight } from "./flights.js"; + +export const fetchIssues = filter => flight("fetchIssues", [filter]); +export const fetchIssue = id => flight("fetchIssue", [id]); +export const assign = (id, user) => flight("assign", [id, user]); +export const renameIssue = (id, title) => flight("renameIssue", [id, title]); +export const closeIssue = id => flight("closeIssue", [id]); +export const fetchComments = id => flight("fetchComments", [id]); +export const addComment = (id, author, text) => flight("addComment", [id, author, text]); +export const search = q => flight("search", [q]); +export const fetchItem = id => flight("fetchItem", [id]); +export const loadSettings = () => flight("loadSettings", []); +export const saveSettings = s => flight("saveSettings", [s]); +export const fetchLabels = id => flight("fetchLabels", [id]); +export const addLabel = (id, label) => flight("addLabel", [id, label]); +export const removeLabel = (id, label) => flight("removeLabel", [id, label]); diff --git a/eval/tasks/chain3/brief-plain.md b/eval/tasks/chain3/brief-plain.md new file mode 100644 index 0000000..a923894 --- /dev/null +++ b/eval/tasks/chain3/brief-plain.md @@ -0,0 +1,23 @@ +# Change request: related help + +The app in `src/` is an issue desk that already works. `BASE.md` is the brief it was built to, with the changes made since. Keep everything it +does, and show help related to the selected issue, which opens on the help page. + +## The API (`src/api.js`, given; do not change it) + +Everything in `BASE.md`. Nothing new: related help comes from `search`. + +## What to add + +Elements the tests look for carry these `data-testid`s. Text in quotes must appear exactly as written. + +1. **Related help.** While an issue is selected and its detail has loaded, the detail has an element `related`. + - It calls `search(<the issue's title>)` (for `#3 Crash on save`: `search("Crash on save")`), and shows + `finding related help` until the hits arrive. + - Then one button per hit, at most the first three, `data-testid="related-hit"`, reading the hit's name; or + `no related help` when there are none. + - If the search fails: `related help failed: <message>`. +2. **Opening a related hit.** Clicking a `related-hit` opens the help page as if the user had typed the issue's title + into `query` and clicked that hit: `query` holds the title, the results area shows the results for it, the hit is + selected (`> <name>`), and the help pane shows the item from `fetchItem(id)` (`loading…` until it answers). +3. Opening the help page with its tab still starts it as before: empty query, nothing selected. diff --git a/eval/tasks/chain3/fixtures.mjs b/eval/tasks/chain3/fixtures.mjs new file mode 100644 index 0000000..9639746 --- /dev/null +++ b/eval/tasks/chain3/fixtures.mjs @@ -0,0 +1,61 @@ +// Issues +export const OPEN = [{ id: 1, title: "Login fails", assignee: null }, { id: 3, title: "Crash on save", assignee: null }, { id: 4, title: "Typo in footer", assignee: "dana" }]; +export const CLOSED = [{ id: 2, title: "Old bug", assignee: "eve" }]; +export const OPEN_ROWS = ["#1 Login fails (unassigned)", "#3 Crash on save (unassigned)", "#4 Typo in footer (dana)"]; +export const D1 = { id: 1, title: "Login fails", body: "on Safari only" }; +export const D3 = { id: 3, title: "Crash on save", body: "stack trace attached" }; +export const C3 = [{ author: "dana", text: "seen it too" }]; +export const C1 = []; +/** the open issues loaded */ +export async function loaded(app) { await app.land("fetchIssues:open", OPEN); } +/** the open issues loaded and issue `id` selected with its detail */ +export async function selected(app, id = 3, detail = D3) { await loaded(app); await app.clickText("issue", `#${id} `); await app.land(`fetchIssue:${id}`, detail); } +/** issue 3 selected with its detail and its comments */ +export async function withComments(app) { await selected(app); await app.land("fetchComments:3", C3); } +/** type `title` into the detail's title input and click rename */ +export async function rename(app, title) { await app.type("title-input", title); await app.click("rename"); } +/** type `text` into the comment input and click post */ +export async function post(app, text) { await app.type("comment-input", text); await app.click("post"); } +export const openWithout = id => OPEN.filter(i => i.id !== id); +export const rowsWithout = id => OPEN_ROWS.filter(r => !r.startsWith(`#${id} `)); + +// Help +export const AP = [{ id: 1, name: "apple" }, { id: 2, name: "apricot" }]; +export const APP = [{ id: 1, name: "apple" }, { id: 3, name: "applesauce" }, { id: 4, name: "apple pie" }]; +export const ITEM3 = { id: 3, name: "applesauce", blurb: "stewed apples" }; +export const ITEM4 = { id: 4, name: "apple pie", blurb: "baked with cinnamon" }; +/** type a query and land its answer */ +export async function searched(app, q, hits) { await app.type("query", q); await app.land(`search:${q}`, hits); } + +// Settings +export const ANN = { name: "Ann", theme: "light", density: "compact" }; +export async function settingsLoaded(app) { await app.land("loadSettings:", ANN); } +/** loaded, the name typed, the theme picked, then saved: returns the request's key */ +export async function saving(app, name, theme) { + await settingsLoaded(app); + await app.type("name", name); + if (theme) await app.click(`theme-${theme}`); + await app.click("save"); + return app.only("saveSettings"); +} + +/** open the help or settings tab first */ +export async function open(app, page) { await app.click(`tab-${page}`); } + +// Labels +export const L3 = ["bug", "ui"]; +export const L1 = []; +/** issue 3 selected with its detail, comments and labels */ +export async function withLabels(app) { await selected(app); await app.landIfAsked("fetchComments:3", C3); await app.land("fetchLabels:3", L3); } +/** type `label` into the label input and click add-label */ +export async function addLabel(app, label) { await app.type("label-input", label); await app.click("add-label"); } +/** click the remove button of `label` */ +export async function removeLabel(app, label) { await app.clickText("remove-label", `remove ${label}`); } + +// Related help +export const REL3 = [{ id: 7, name: "saving files" }, { id: 8, name: "crash reports" }, { id: 9, name: "autosave" }, { id: 10, name: "backups" }]; +export const REL1 = [{ id: 11, name: "signing in" }]; +export const ITEM8 = { id: 8, name: "crash reports", blurb: "send us the log" }; +/** issue 3 selected with its detail and its related help */ +export async function withRelated(app) { await selected(app); await app.land("search:Crash on save", REL3); } + diff --git a/eval/tasks/chain3/hidden-overlap.mjs b/eval/tasks/chain3/hidden-overlap.mjs new file mode 100644 index 0000000..84385db --- /dev/null +++ b/eval/tasks/chain3/hidden-overlap.mjs @@ -0,0 +1,7 @@ +// Round 7, chain step 3: every step's hidden-overlap tests so far, deduplicated by name. Never shown to the agent. +import s0 from "../labels/hidden-overlap.mjs"; +import s1 from "../count/hidden-overlap.mjs"; +import s2 from "../related/hidden-overlap.mjs"; + +const seen = new Set(); +export default [...s0, ...s1, ...s2].filter(t => !seen.has(t.name) && seen.add(t.name)); diff --git a/eval/tasks/chain3/hidden.mjs b/eval/tasks/chain3/hidden.mjs new file mode 100644 index 0000000..58e3e66 --- /dev/null +++ b/eval/tasks/chain3/hidden.mjs @@ -0,0 +1,7 @@ +// Round 7, chain step 3: every step's hidden tests so far, deduplicated by name. Never shown to the agent. +import s0 from "../labels/hidden.mjs"; +import s1 from "../count/hidden.mjs"; +import s2 from "../related/hidden.mjs"; + +const seen = new Set(); +export default [...s0, ...s1, ...s2].filter(t => !seen.has(t.name) && seen.add(t.name)); diff --git a/eval/tasks/chain3/visible.mjs b/eval/tasks/chain3/visible.mjs new file mode 100644 index 0000000..60663dd --- /dev/null +++ b/eval/tasks/chain3/visible.mjs @@ -0,0 +1,206 @@ +// Visible tests: the agent sees and runs these. The desk's, then each change's so far. +import { ANN, AP, APP, C1, C3, CLOSED, D1, D3, ITEM3, ITEM4, ITEM8, L1, L3, OPEN, OPEN_ROWS, REL1, REL3, addLabel, loaded, open, openWithout, post, removeLabel, rename, rowsWithout, saving, searched, selected, settingsLoaded, withComments, withLabels, withRelated } from "./fixtures.mjs"; + +export default [ + { name: "on load: the open issues load", async run(app) { + await app.see("list", "loading open issues"); + await app.see("detail", "select an issue"); + await loaded(app); + await app.rows("issue", OPEN_ROWS); + } }, + { name: "switching the filter loads the closed issues", async run(app) { + await loaded(app); + await app.click("filter-closed"); + await app.see("list", "loading closed issues"); + await app.land("fetchIssues:closed", CLOSED); + await app.rows("issue", ["#2 Old bug (eve)"]); + } }, + { name: "selecting an issue loads its detail", async run(app) { + await loaded(app); + await app.clickText("issue", "#3 "); + await app.see("detail", "loading #3"); + await app.land("fetchIssue:3", D3); + await app.see("detail", "#3 Crash on save", "stack trace attached"); + } }, + { name: "assigning shows the new assignee at once", async run(app) { + await selected(app); + await app.type("user", "ann"); + await app.see("assign", "assign to ann"); + await app.click("assign"); + await app.inAir("assign:3,ann"); + await app.see("list", "#3 Crash on save (ann)"); + await app.land("assign:3,ann", { id: 3, assignee: "ann" }); + await app.see("list", "#3 Crash on save (ann)"); + } }, + { name: "renaming shows the new title at once in the row and the heading", async run(app) { + await selected(app); + await rename(app, "Crash at boot"); + await app.inAir("renameIssue:3,Crash at boot"); + await app.see("list", "#3 Crash at boot (unassigned)"); + await app.see("detail", "#3 Crash at boot"); + } }, + { name: "an accepted rename keeps the new title", async run(app) { + await selected(app); + await rename(app, "Crash at boot"); + await app.land("renameIssue:3,Crash at boot", { id: 3, title: "Crash at boot" }); + await app.see("list", "#3 Crash at boot (unassigned)"); + await app.see("detail", "#3 Crash at boot"); + } }, + { name: "closing removes the row at once and keeps the detail", async run(app) { + await selected(app); + await app.click("close"); + await app.inAir("closeIssue:3"); + await app.rows("issue", rowsWithout(3)); + await app.see("detail", "#3 Crash on save"); + } }, + { name: "an accepted close keeps the row out", async run(app) { + await selected(app); + await app.click("close"); + await app.land("closeIssue:3", { id: 3 }); + await app.rows("issue", rowsWithout(3)); + } }, + { name: "selecting an issue loads its comments", async run(app) { + await selected(app); + await app.see("comments", "loading comments"); + await app.land("fetchComments:3", [{ author: "dana", text: "seen it too" }]); + await app.rows("comment", ["dana: seen it too"]); + } }, + { name: "posting shows the comment at once and keeps it when accepted", async run(app) { + await withComments(app); + await app.type("user", "ann"); + await post(app, "on it"); + await app.inAir("addComment:3,ann,on it"); + await app.rows("comment", ["dana: seen it too", "ann: on it (sending…)"]); + await app.hasValue("comment-input", ""); + await app.land("addComment:3,ann,on it", { author: "ann", text: "on it" }); + await app.landIfAsked("fetchComments:3", [{ author: "dana", text: "seen it too" }, { author: "ann", text: "on it" }]); + await app.rows("comment", ["dana: seen it too", "ann: on it"]); + } }, + { name: "help on open: nothing selected, nothing asked", async run(app) { + await open(app, "help"); + await app.see("item", "pick a result"); + if ((await app.asked()).some(k => /^(search|fetchItem):/.test(k))) throw new Error(`asked ${await app.asked()} on open`); + } }, + { name: "one character is too short and asks nothing", async run(app) { + await open(app, "help"); + await app.type("query", "a"); + await app.see("results", "too short: a"); + await app.notAsked("search:a"); + } }, + { name: "a search shows searching…, then its results", async run(app) { + await open(app, "help"); + await app.type("query", "ap"); + await app.inAir("search:ap"); + await app.see("results", "searching…"); + await app.land("search:ap", AP); + await app.see("results", "2 results for ap"); + await app.notSee("results", "searching…"); + await app.rows("hit", ["apple", "apricot"]); + } }, + { name: "selecting a hit loads its detail", async run(app) { + await open(app, "help"); + await searched(app, "app", APP); + await app.clickText("hit", "applesauce"); + await app.rows("hit", ["apple", "> applesauce", "apple pie"]); + await app.see("item", "loading…"); + await app.land("fetchItem:3", ITEM3); + await app.see("item", "applesauce", "stewed apples"); + } }, + { name: "loads, then shows the saved line and the form", async run(app) { + await open(app, "settings"); + await app.see("load", "Loading settings…"); + await settingsLoaded(app); + await app.eq("saved", "Saved: Ann · light · compact"); + await app.hasValue("name", "Ann"); + await app.eq("name-count", "3/20"); + await app.eq("preview", "Preview: light / compact"); + } }, + { name: "validates the name", async run(app) { + await open(app, "settings"); + await settingsLoaded(app); + await app.type("name", ""); + await app.see("name-error", "Display name is required"); + await app.type("name", "Bartholomew the Magnificent"); + await app.see("name-error", "Display name must be at most 20 characters, not 27"); + await app.eq("name-count", "27/20"); + await app.type("name", "Bea"); + await app.notSee("name-error", "Display name"); + } }, + { name: "a save shows at once, then lands", async run(app) { + await open(app, "settings"); + await saving(app, "Bea", "dark"); + await app.eq("saved", "Saved: Bea · dark · compact (saving…)"); + await app.landOnly("saveSettings", { name: "Bea", theme: "dark", density: "compact" }); + await app.eq("saved", "Saved: Bea · dark · compact"); + } }, + { name: "a failed load says so", async run(app) { + await open(app, "settings"); + await app.fail("loadSettings:", "network down"); + await app.see("load", "Could not load settings: network down"); + } }, + { name: "tabs: settings, then back to issues, starts the issues page again", async run(app) { + await selected(app); + await open(app, "settings"); + await app.see("load", "Loading settings…"); + await app.notSee("list", "Crash on save"); + await open(app, "issues"); + await app.see("list", "loading open issues"); + await app.see("detail", "select an issue"); + } }, + { name: "selecting an issue loads its labels", async run(app) { + await selected(app); + await app.see("labels", "loading labels"); + await app.landIfAsked("fetchComments:3", C3); + await app.land("fetchLabels:3", L3); + await app.rows("label", ["bug", "ui"]); + } }, + { name: "adding a label shows it at once and keeps it when accepted", async run(app) { + await withLabels(app); + await addLabel(app, "crash"); + await app.inAir("addLabel:3,crash"); + await app.rows("label", ["bug", "ui", "crash"]); + await app.hasValue("label-input", ""); + await app.land("addLabel:3,crash", [...L3, "crash"]); + await app.landIfAsked("fetchLabels:3", [...L3, "crash"]); + await app.rows("label", ["bug", "ui", "crash"]); + } }, + { name: "removing a label takes it out at once", async run(app) { + await withLabels(app); + await removeLabel(app, "bug"); + await app.inAir("removeLabel:3,bug"); + await app.rows("label", ["ui"]); + await app.land("removeLabel:3,bug", ["ui"]); + await app.landIfAsked("fetchLabels:3", ["ui"]); + await app.rows("label", ["ui"]); + } }, + { name: "the issues tab shows the open count once the open issues load", async run(app) { + await app.eq("tab-issues", "Issues"); + await loaded(app); + await app.eq("tab-issues", "Issues (3)"); + } }, + { name: "closing takes one off the count at once and keeps it off when accepted", async run(app) { + await selected(app); + await app.click("close"); + await app.eq("tab-issues", "Issues (2)"); + await app.land("closeIssue:3", { id: 3 }); + await app.landIfAsked("fetchIssues:open", openWithout(3)); + await app.eq("tab-issues", "Issues (2)"); + } }, + { name: "a selected issue shows its related help", async run(app) { + await selected(app); + await app.see("related", "finding related help"); + await app.land("search:Crash on save", REL3); + await app.rows("related-hit", ["saving files", "crash reports", "autosave"]); + } }, + { name: "opening a related hit shows it on the help page", async run(app) { + await withRelated(app); + await app.clickText("related-hit", "crash reports"); + await app.hasValue("query", "Crash on save"); + await app.landIfAsked("search:Crash on save", REL3); + await app.see("results", "4 results for Crash on save"); + await app.rows("hit", ["saving files", "> crash reports", "autosave", "backups"]); + await app.see("item", "loading…"); + await app.land("fetchItem:8", ITEM8); + await app.see("item", "crash reports", "send us the log"); + } }, +]; diff --git a/eval/tasks/chain4/BASE.md b/eval/tasks/chain4/BASE.md new file mode 100644 index 0000000..9aada52 --- /dev/null +++ b/eval/tasks/chain4/BASE.md @@ -0,0 +1,237 @@ +The app was built to the brief below, then changed by the 3 change requests after it, in order. + +# Issue desk + +Build an issue desk: three pages behind a row of tabs. The issues page is an issue tracker with assigning, renaming, +closing and comments; the help page searches a help catalogue; the settings page edits the user's settings. + +## The API (`src/api.js`, given; do not change it) + +Issues: + +- `fetchIssues(filter: "open" | "closed"): Promise<{ id: number; title: string; assignee: string | null }[]>`. May + reject with an `Error`. +- `fetchIssue(id: number): Promise<{ id: number; title: string; body: string } | null>`: `null` when there is no such + issue. May reject with an `Error`. +- `assign(id: number, user: string): Promise<{ id: number; assignee: string }>`: assigns the issue on the server. May + reject with an `Error` (the server declined). +- `renameIssue(id: number, title: string): Promise<{ id: number; title: string }>`: renames the issue on the server. + May reject with an `Error` (the server declined). +- `closeIssue(id: number): Promise<{ id: number }>`: closes the issue on the server. May reject with an `Error` (the + server declined). +- `fetchComments(id: number): Promise<{ author: string; text: string }[]>`: the issue's comments, oldest first. May + reject with an `Error`. +- `addComment(id: number, author: string, text: string): Promise<{ author: string; text: string }>`: adds a comment + on the server. May reject with an `Error` (the server declined). + +Help: + +- `search(q: string): Promise<{ id: number; name: string }[]>`: the hits for `q`, in order. It may reject with an + `Error`. +- `fetchItem(id: number): Promise<{ id: number; name: string; blurb: string } | null>`: one item, or `null` when there + is no item with that id. It may reject with an `Error`. + +Settings: + +- `loadSettings(): Promise<{ name: string; theme: "light" | "dark"; density: "comfortable" | "compact" }>`. May + reject with an `Error`. +- `saveSettings(s: { name: string; theme: "light" | "dark"; density: "comfortable" | "compact" }): Promise<same shape>`: + answers the settings as saved. May reject with an `Error`. + +## What the app must do + +Elements the tests look for carry these `data-testid`s. Text in quotes must appear exactly as written. + +### Tabs + +1. Three buttons, `tab-issues`, `tab-help` and `tab-settings`, always on screen. The app opens on the issues page. +2. Only the open page is on screen. Leaving a page discards it: opening it again starts it as it starts when the app + opens (it loads again, nothing selected, inputs at their starting values). A request a discarded page made may + still answer; it must not show on, or change, any page. + +### The issues page + +1. **Current user.** A text input, `user`, holding the current user's name. It starts as `guest`. +2. **Filter.** Two buttons, `filter-open` and `filter-closed`. The filter starts as `open`. +3. **List**, `list`, of the issues for the current filter, from `fetchIssues(filter)`. + - While they load (when the page opens or the filter changes): `loading <filter> issues` (`loading closed issues`). + - Then one row per issue, `data-testid="issue"`, reading `#<id> <title> (<assignee>)`, or `(unassigned)` when the + assignee is `null`: `#3 Crash on save (unassigned)`. + - If it fails: `list error: <message>`. +4. **Detail**, `detail`. + - Nothing selected: `select an issue`. Clicking a row selects its issue. + - The selected issue comes from `fetchIssue(id)`. While it loads: `loading #<id>`. + - An issue: `#<id> <title>` and its body. + - `null`: `detail error: issue #<id> not found`. A failure: `detail error: <message>`. + - A button `retry` (`data-testid="retry"`) calls `fetchIssue(id)` again and shows its answer. +5. **Assign.** While an issue is selected, the detail has a button `assign` (`data-testid="assign"`) reading + `assign to <current user>`. Clicking it: + - shows the current user as the issue's assignee in its list row straight away; + - calls `assign(id, user)`; + - if the server declines, the row goes back to the assignee it had before, and the detail shows the server's + message (`declined`); + - if the server accepts, the row keeps the new assignee. + - An assignment error is cleared when a new assignment starts or another issue is selected. +6. **Rename.** While an issue is selected, the detail has a text input `title-input`, empty when the issue is + selected, and a button `rename` (`data-testid="rename"`). Clicking `rename` while `title-input` holds a title: + - shows the new title in the issue's list row and in the detail's heading (`#<id> <title>`) straight away; + - calls `renameIssue(id, title)`; + - if the server declines, the row and the heading go back to the title they had before, and the detail shows + `rename error: <message>` (`rename error: declined`); + - if the server accepts, the row and the heading keep the new title. + - A rename error is cleared when a new rename starts or another issue is selected. +7. **Close.** While an issue is selected, the detail has a button `close` (`data-testid="close"`). Clicking it: + - removes the issue's row from the list of open issues straight away (the detail keeps showing the issue); + - calls `closeIssue(id)`; + - if the server declines, the row comes back where it was, and the detail shows `close error: <message>` + (`close error: declined`); + - if the server accepts, the row stays out of the open issues. + - A close error is cleared when a new close starts or another issue is selected. +8. **Comments.** While an issue is selected, the detail has an element `comments` with that issue's comments, from + `fetchComments(id)`. + - While they load: `loading comments`. + - Then one row per comment, `data-testid="comment"`, reading `<author>: <text>` (`dana: seen it too`). + - If it fails: `comments error: <message>`. +9. **Posting.** The detail has a text input `comment-input` and a button `post` (`data-testid="post"`). Clicking + `post` while `comment-input` holds text: + - adds the comment as the last row straight away, reading `<current user>: <text> (sending…)`, and empties the + input; + - calls `addComment(id, user, text)`; + - if the server declines, the row goes away, and the detail shows `post error: <message>` (`post error: declined`); + - if the server accepts, the row stays and reads `<current user>: <text>`. + - A post error is cleared when a new post starts or another issue is selected. + +### The help page + +1. A text input, `query`. What the user types is the query. +2. A results area, `results`. + - Empty query: the results area shows nothing, and nothing is asked. + - A query of one character: the results area shows `too short: <q>` (for `a`: `too short: a`). Nothing is asked. + - Two characters or more: call `search(q)` and show `searching…` until the results arrive. Results already on + screen stay there until the new ones replace them. + - The results: a line `<n> results for <q>` (`2 results for ap`) and one list item per hit, `data-testid="hit"`, + whose text is the hit's name. + - If the search fails: `search failed: <message>`. +3. Clicking a hit selects it. The selected hit's text starts with `> ` (`> apple pie`); the others have no prefix. +4. A help pane, `item`. + - Nothing selected: `pick a result`. + - When a hit is selected, call `fetchItem(id)` and show `loading…` until it answers. + - The answer is an item: the pane shows its name and its blurb. + - The answer is `null`: `item <id> not found`. + - It fails: `failed: <message>` and a button `retry-item` (`data-testid="retry-item"`) that calls `fetchItem(id)` + again and shows its answer. + +### The settings page + +1. **Loading.** Call `loadSettings()` when the page opens. Until it answers, an element `load` shows + `Loading settings…`. If it fails, `load` shows `Could not load settings: <message>` and a button `retry-load` + calls `loadSettings()` again and shows the settings it answers. +2. **Saved line**, `saved`, once the settings have loaded: `Saved: <name> · <theme> · <density>` + (`Saved: Ann · light · compact`). +3. **The draft.** Once loaded, a form is filled from the loaded settings and then belongs to the user: + - an input `name` (the display name), a counter `name-count` reading `<length>/20`; + - `name-error`, shown only when the name is invalid: `Display name is required` when empty, + `Display name must be at most 20 characters, not <length>` when longer than 20; + - buttons `theme-light` and `theme-dark` that set the draft's theme; a button `density-toggle` that switches the + draft's density between `comfortable` and `compact`; + - `preview` reading `Preview: <draft theme> / <draft density>`. +4. **Save**, a button `save`. + - With an invalid name it does nothing (nothing is asked). + - Otherwise it calls `saveSettings(draft)`. Straight away the saved line shows the values being saved followed by + ` (saving…)` (`Saved: Bea · dark · compact (saving…)`), and any previous save error disappears. + - When the save succeeds, the saved line shows the saved settings, without `(saving…)`. + - When it fails, the saved line goes back to what was saved before, without `(saving…)`, and `save-error` shows + `Save failed: <message>`. + - Saving never changes the draft. + +--- + +## Change 1: labels + +The app in `src/` is an issue desk that already works. `BASE.md` is the brief it was built to. Keep everything it +does, and add labels to the selected issue. + +### The API (`src/api.js`, given; do not change it) + +Everything in `BASE.md`, and three new functions: + +- `fetchLabels(id: number): Promise<string[]>`: the issue's labels, in order. May reject with an `Error`. +- `addLabel(id: number, label: string): Promise<string[]>`: adds a label at the end on the server; answers the + issue's labels after the change. May reject with an `Error` (the server declined). +- `removeLabel(id: number, label: string): Promise<string[]>`: removes a label on the server; answers the issue's + labels after the change. May reject with an `Error` (the server declined). + +### What to add + +Elements the tests look for carry these `data-testid`s. Text in quotes must appear exactly as written. + +1. **Labels.** While an issue is selected, the detail has an element `labels` with that issue's labels, from + `fetchLabels(id)`. + - While they load: `loading labels`. + - Then, per label, an element `data-testid="label"` reading the label (`bug`), and next to it a button + `data-testid="remove-label"` reading `remove <label>` (`remove bug`). + - If it fails: `labels error: <message>`. +2. **Adding.** The detail has a text input `label-input` and a button `add-label`. Clicking `add-label` while + `label-input` holds a label the issue does not have yet: + - adds the label as the last one straight away and empties the input; + - calls `addLabel(id, label)`; + - if the server declines, the label goes away, and the detail shows `label error: <message>` + (`label error: declined`); + - if the server accepts, the label stays. + - A label the issue already shows is not added again, and nothing is asked. +3. **Removing.** Clicking a label's `remove-label` button: + - removes the label straight away; + - calls `removeLabel(id, label)`; + - if the server declines, the label comes back where it was, and the detail shows `label error: <message>`; + - if the server accepts, the label stays out. +4. A label error is cleared when a new label change (an add or a remove) starts or another issue is selected. + +--- + +## Change 2: open-issue count + +The app in `src/` is an issue desk that already works. `BASE.md` is the brief it was built to. Keep everything it +does, and show how many issues are open on the issues tab. + +### The API (`src/api.js`, given; do not change it) + +Everything in `BASE.md`. Nothing new. + +### What to add + +Elements the tests look for carry these `data-testid`s. Text in quotes must appear exactly as written. + +1. The `tab-issues` button reads `Issues (<n>)`, where `<n>` is the number of rows in the list of open issues + (`Issues (3)`). It shows on every page. Until the open issues have loaded once, it reads `Issues`. +2. The count follows the list of open issues as the issues page shows it: whenever the open issues load again (the + issues page opening again, or its filter switching back to open), the count follows the new answer. While the + list shows closed issues, is loading, or failed, and while another page is open, the count keeps the last number + it showed. +3. **Closing.** Clicking `close` on an open issue takes one off the count straight away, as its row leaves the + list. If the server declines, the count goes back; if it accepts, the count stays down. + +--- + +## Change 3: related help + +The app in `src/` is an issue desk that already works. `BASE.md` is the brief it was built to. Keep everything it +does, and show help related to the selected issue, which opens on the help page. + +### The API (`src/api.js`, given; do not change it) + +Everything in `BASE.md`. Nothing new: related help comes from `search`. + +### What to add + +Elements the tests look for carry these `data-testid`s. Text in quotes must appear exactly as written. + +1. **Related help.** While an issue is selected and its detail has loaded, the detail has an element `related`. + - It calls `search(<the issue's title>)` (for `#3 Crash on save`: `search("Crash on save")`), and shows + `finding related help` until the hits arrive. + - Then one button per hit, at most the first three, `data-testid="related-hit"`, reading the hit's name; or + `no related help` when there are none. + - If the search fails: `related help failed: <message>`. +2. **Opening a related hit.** Clicking a `related-hit` opens the help page as if the user had typed the issue's title + into `query` and clicked that hit: `query` holds the title, the results area shows the results for it, the hit is + selected (`> <name>`), and the help pane shows the item from `fetchItem(id)` (`loading…` until it answers). +3. Opening the help page with its tab still starts it as before: empty query, nothing selected. diff --git a/eval/tasks/chain4/api.d.rx b/eval/tasks/chain4/api.d.rx new file mode 100644 index 0000000..f66befd --- /dev/null +++ b/eval/tasks/chain4/api.d.rx @@ -0,0 +1,23 @@ +// src/api.js, as .rx sees it. fetchIssues, fetchIssue, fetchComments, fetchLabels, search, fetchItem and loadSettings +// are loaders (safe to ask from an equation); assign, renameIssue, closeIssue, addComment, addLabel, removeLabel and +// saveSettings write. +export type Issue = { id: number; title: string; assignee: string | null } +export type IssueDetail = { id: number; title: string; body: string } +export pure fn fetchIssues(filter: string): Promise<Issue[]> +export pure fn fetchIssue(id: number): Promise<IssueDetail | null> +export fn assign(id: number, user: string): Promise<{ id: number; assignee: string }> +export fn renameIssue(id: number, title: string): Promise<{ id: number; title: string }> +export fn closeIssue(id: number): Promise<{ id: number }> +export type Comment = { author: string; text: string } +export pure fn fetchComments(id: number): Promise<Comment[]> +export fn addComment(id: number, author: string, text: string): Promise<Comment> +export type Hit = { id: number; name: string } +export type Item = { id: number; name: string; blurb: string } +export pure fn search(q: string): Promise<Hit[]> +export pure fn fetchItem(id: number): Promise<Item | null> +export type Settings = { name: string; theme: string; density: string } +export pure fn loadSettings(): Promise<Settings> +export fn saveSettings(s: Settings): Promise<Settings> +export pure fn fetchLabels(id: number): Promise<string[]> +export fn addLabel(id: number, label: string): Promise<string[]> +export fn removeLabel(id: number, label: string): Promise<string[]> diff --git a/eval/tasks/chain4/api.d.ts b/eval/tasks/chain4/api.d.ts new file mode 100644 index 0000000..0acb5a4 --- /dev/null +++ b/eval/tasks/chain4/api.d.ts @@ -0,0 +1,22 @@ +export type Issue = { id: number; title: string; assignee: string | null }; +export type IssueDetail = { id: number; title: string; body: string }; +export function fetchIssues(filter: "open" | "closed"): Promise<Issue[]>; +export function fetchIssue(id: number): Promise<IssueDetail | null>; +export function assign(id: number, user: string): Promise<{ id: number; assignee: string }>; +export function renameIssue(id: number, title: string): Promise<{ id: number; title: string }>; +export function closeIssue(id: number): Promise<{ id: number }>; +export type Comment = { author: string; text: string }; +export function fetchComments(id: number): Promise<Comment[]>; +export function addComment(id: number, author: string, text: string): Promise<Comment>; +export type Hit = { id: number; name: string }; +export type Item = { id: number; name: string; blurb: string }; +export function search(q: string): Promise<Hit[]>; +export function fetchItem(id: number): Promise<Item | null>; +export type Theme = "light" | "dark"; +export type Density = "comfortable" | "compact"; +export type Settings = { name: string; theme: Theme; density: Density }; +export function loadSettings(): Promise<Settings>; +export function saveSettings(s: Settings): Promise<Settings>; +export function fetchLabels(id: number): Promise<string[]>; +export function addLabel(id: number, label: string): Promise<string[]>; +export function removeLabel(id: number, label: string): Promise<string[]>; diff --git a/eval/tasks/chain4/api.js b/eval/tasks/chain4/api.js new file mode 100644 index 0000000..ac4c81c --- /dev/null +++ b/eval/tasks/chain4/api.js @@ -0,0 +1,16 @@ +import { flight } from "./flights.js"; + +export const fetchIssues = filter => flight("fetchIssues", [filter]); +export const fetchIssue = id => flight("fetchIssue", [id]); +export const assign = (id, user) => flight("assign", [id, user]); +export const renameIssue = (id, title) => flight("renameIssue", [id, title]); +export const closeIssue = id => flight("closeIssue", [id]); +export const fetchComments = id => flight("fetchComments", [id]); +export const addComment = (id, author, text) => flight("addComment", [id, author, text]); +export const search = q => flight("search", [q]); +export const fetchItem = id => flight("fetchItem", [id]); +export const loadSettings = () => flight("loadSettings", []); +export const saveSettings = s => flight("saveSettings", [s]); +export const fetchLabels = id => flight("fetchLabels", [id]); +export const addLabel = (id, label) => flight("addLabel", [id, label]); +export const removeLabel = (id, label) => flight("removeLabel", [id, label]); diff --git a/eval/tasks/chain4/brief-plain.md b/eval/tasks/chain4/brief-plain.md new file mode 100644 index 0000000..f88c332 --- /dev/null +++ b/eval/tasks/chain4/brief-plain.md @@ -0,0 +1,31 @@ +# Change request: row actions + +The app in `src/` is an issue desk that already works. `BASE.md` is the brief it was built to, with the changes made +since. Keep everything it does, and let the user assign and close an issue from its row in the list, without +selecting it. + +## The API (`src/api.js`, given; do not change it) + +Everything in `BASE.md`. Nothing new. + +## What to add + +Elements the tests look for carry these `data-testid`s. Text in quotes must appear exactly as written. + +1. **Row buttons.** Next to each row of the list, in the row order, two buttons. They sit outside the row's `issue` + element, which keeps reading exactly as before: + - `row-assign`, reading `assign to <current user>` (`assign to guest`); + - `row-close`, reading `close`, only while the list shows open issues. + Clicking a row button does not select its issue. +2. **Row assign.** Clicking `row-assign` does for that row's issue what the detail's `assign` does: the row shows the + current user as its assignee straight away; it calls `assign(id, user)`; if the server declines, the row goes back + to the assignee it had before; if the server accepts, the row keeps the new assignee. +3. **Row close.** Clicking `row-close` does for that row's issue what the detail's `close` does: the row leaves the + list of open issues straight away (if that issue is selected, the detail keeps showing it); it calls + `closeIssue(id)`; if the server declines, the row comes back where it was; if the server accepts, it stays out. + The `tab-issues` count follows row closes as it follows the detail's. +4. **Row errors.** When the server declines a row action, the issues page shows `row error: <message>` in an + element `row-error` (`row error: declined`). A row error is cleared when the next row action starts. Row actions + do not show or clear the detail's errors, and the detail's actions do not clear a row error. +5. While an issue has more than one assignment out, from its row or its detail, its row shows the newest one + started until that one is answered. diff --git a/eval/tasks/chain4/fixtures.mjs b/eval/tasks/chain4/fixtures.mjs new file mode 100644 index 0000000..faf70bb --- /dev/null +++ b/eval/tasks/chain4/fixtures.mjs @@ -0,0 +1,66 @@ +// Issues +export const OPEN = [{ id: 1, title: "Login fails", assignee: null }, { id: 3, title: "Crash on save", assignee: null }, { id: 4, title: "Typo in footer", assignee: "dana" }]; +export const CLOSED = [{ id: 2, title: "Old bug", assignee: "eve" }]; +export const OPEN_ROWS = ["#1 Login fails (unassigned)", "#3 Crash on save (unassigned)", "#4 Typo in footer (dana)"]; +export const D1 = { id: 1, title: "Login fails", body: "on Safari only" }; +export const D3 = { id: 3, title: "Crash on save", body: "stack trace attached" }; +export const C3 = [{ author: "dana", text: "seen it too" }]; +export const C1 = []; +/** the open issues loaded */ +export async function loaded(app) { await app.land("fetchIssues:open", OPEN); } +/** the open issues loaded and issue `id` selected with its detail */ +export async function selected(app, id = 3, detail = D3) { await loaded(app); await app.clickText("issue", `#${id} `); await app.land(`fetchIssue:${id}`, detail); } +/** issue 3 selected with its detail and its comments */ +export async function withComments(app) { await selected(app); await app.land("fetchComments:3", C3); } +/** type `title` into the detail's title input and click rename */ +export async function rename(app, title) { await app.type("title-input", title); await app.click("rename"); } +/** type `text` into the comment input and click post */ +export async function post(app, text) { await app.type("comment-input", text); await app.click("post"); } +export const openWithout = id => OPEN.filter(i => i.id !== id); +export const rowsWithout = id => OPEN_ROWS.filter(r => !r.startsWith(`#${id} `)); + +// Help +export const AP = [{ id: 1, name: "apple" }, { id: 2, name: "apricot" }]; +export const APP = [{ id: 1, name: "apple" }, { id: 3, name: "applesauce" }, { id: 4, name: "apple pie" }]; +export const ITEM3 = { id: 3, name: "applesauce", blurb: "stewed apples" }; +export const ITEM4 = { id: 4, name: "apple pie", blurb: "baked with cinnamon" }; +/** type a query and land its answer */ +export async function searched(app, q, hits) { await app.type("query", q); await app.land(`search:${q}`, hits); } + +// Settings +export const ANN = { name: "Ann", theme: "light", density: "compact" }; +export async function settingsLoaded(app) { await app.land("loadSettings:", ANN); } +/** loaded, the name typed, the theme picked, then saved: returns the request's key */ +export async function saving(app, name, theme) { + await settingsLoaded(app); + await app.type("name", name); + if (theme) await app.click(`theme-${theme}`); + await app.click("save"); + return app.only("saveSettings"); +} + +/** open the help or settings tab first */ +export async function open(app, page) { await app.click(`tab-${page}`); } + +// Labels +export const L3 = ["bug", "ui"]; +export const L1 = []; +/** issue 3 selected with its detail, comments and labels */ +export async function withLabels(app) { await selected(app); await app.landIfAsked("fetchComments:3", C3); await app.land("fetchLabels:3", L3); } +/** type `label` into the label input and click add-label */ +export async function addLabel(app, label) { await app.type("label-input", label); await app.click("add-label"); } +/** click the remove button of `label` */ +export async function removeLabel(app, label) { await app.clickText("remove-label", `remove ${label}`); } + +// Related help +export const REL3 = [{ id: 7, name: "saving files" }, { id: 8, name: "crash reports" }, { id: 9, name: "autosave" }, { id: 10, name: "backups" }]; +export const REL1 = [{ id: 11, name: "signing in" }]; +export const ITEM8 = { id: 8, name: "crash reports", blurb: "send us the log" }; +/** issue 3 selected with its detail and its related help */ +export async function withRelated(app) { await selected(app); await app.land("search:Crash on save", REL3); } + +// Row actions +/** click the row-assign button of the `n`th row (from 0) */ +export async function rowAssign(app, n) { await app.click("row-assign", n); } +/** click the row-close button of the `n`th row (from 0) */ +export async function rowClose(app, n) { await app.click("row-close", n); } diff --git a/eval/tasks/chain4/hidden-overlap.mjs b/eval/tasks/chain4/hidden-overlap.mjs new file mode 100644 index 0000000..651b953 --- /dev/null +++ b/eval/tasks/chain4/hidden-overlap.mjs @@ -0,0 +1,8 @@ +// Round 7, chain step 4: every step's hidden-overlap tests so far, deduplicated by name. Never shown to the agent. +import s0 from "../labels/hidden-overlap.mjs"; +import s1 from "../count/hidden-overlap.mjs"; +import s2 from "../related/hidden-overlap.mjs"; +import s3 from "../rows/hidden-overlap.mjs"; + +const seen = new Set(); +export default [...s0, ...s1, ...s2, ...s3].filter(t => !seen.has(t.name) && seen.add(t.name)); diff --git a/eval/tasks/chain4/hidden.mjs b/eval/tasks/chain4/hidden.mjs new file mode 100644 index 0000000..0946a91 --- /dev/null +++ b/eval/tasks/chain4/hidden.mjs @@ -0,0 +1,8 @@ +// Round 7, chain step 4: every step's hidden tests so far, deduplicated by name. Never shown to the agent. +import s0 from "../labels/hidden.mjs"; +import s1 from "../count/hidden.mjs"; +import s2 from "../related/hidden.mjs"; +import s3 from "../rows/hidden.mjs"; + +const seen = new Set(); +export default [...s0, ...s1, ...s2, ...s3].filter(t => !seen.has(t.name) && seen.add(t.name)); diff --git a/eval/tasks/chain4/visible.mjs b/eval/tasks/chain4/visible.mjs new file mode 100644 index 0000000..a3095d5 --- /dev/null +++ b/eval/tasks/chain4/visible.mjs @@ -0,0 +1,242 @@ +// Visible tests: the agent sees and runs these. The desk's, then each change's so far. +import { ANN, AP, APP, C1, C3, CLOSED, D1, D3, ITEM3, ITEM4, ITEM8, L1, L3, OPEN, OPEN_ROWS, REL1, REL3, addLabel, loaded, open, openWithout, post, removeLabel, rename, rowAssign, rowClose, rowsWithout, saving, searched, selected, settingsLoaded, withComments, withLabels, withRelated } from "./fixtures.mjs"; + +export default [ + { name: "on load: the open issues load", async run(app) { + await app.see("list", "loading open issues"); + await app.see("detail", "select an issue"); + await loaded(app); + await app.rows("issue", OPEN_ROWS); + } }, + { name: "switching the filter loads the closed issues", async run(app) { + await loaded(app); + await app.click("filter-closed"); + await app.see("list", "loading closed issues"); + await app.land("fetchIssues:closed", CLOSED); + await app.rows("issue", ["#2 Old bug (eve)"]); + } }, + { name: "selecting an issue loads its detail", async run(app) { + await loaded(app); + await app.clickText("issue", "#3 "); + await app.see("detail", "loading #3"); + await app.land("fetchIssue:3", D3); + await app.see("detail", "#3 Crash on save", "stack trace attached"); + } }, + { name: "assigning shows the new assignee at once", async run(app) { + await selected(app); + await app.type("user", "ann"); + await app.see("assign", "assign to ann"); + await app.click("assign"); + await app.inAir("assign:3,ann"); + await app.see("list", "#3 Crash on save (ann)"); + await app.land("assign:3,ann", { id: 3, assignee: "ann" }); + await app.see("list", "#3 Crash on save (ann)"); + } }, + { name: "renaming shows the new title at once in the row and the heading", async run(app) { + await selected(app); + await rename(app, "Crash at boot"); + await app.inAir("renameIssue:3,Crash at boot"); + await app.see("list", "#3 Crash at boot (unassigned)"); + await app.see("detail", "#3 Crash at boot"); + } }, + { name: "an accepted rename keeps the new title", async run(app) { + await selected(app); + await rename(app, "Crash at boot"); + await app.land("renameIssue:3,Crash at boot", { id: 3, title: "Crash at boot" }); + await app.see("list", "#3 Crash at boot (unassigned)"); + await app.see("detail", "#3 Crash at boot"); + } }, + { name: "closing removes the row at once and keeps the detail", async run(app) { + await selected(app); + await app.click("close"); + await app.inAir("closeIssue:3"); + await app.rows("issue", rowsWithout(3)); + await app.see("detail", "#3 Crash on save"); + } }, + { name: "an accepted close keeps the row out", async run(app) { + await selected(app); + await app.click("close"); + await app.land("closeIssue:3", { id: 3 }); + await app.rows("issue", rowsWithout(3)); + } }, + { name: "selecting an issue loads its comments", async run(app) { + await selected(app); + await app.see("comments", "loading comments"); + await app.land("fetchComments:3", [{ author: "dana", text: "seen it too" }]); + await app.rows("comment", ["dana: seen it too"]); + } }, + { name: "posting shows the comment at once and keeps it when accepted", async run(app) { + await withComments(app); + await app.type("user", "ann"); + await post(app, "on it"); + await app.inAir("addComment:3,ann,on it"); + await app.rows("comment", ["dana: seen it too", "ann: on it (sending…)"]); + await app.hasValue("comment-input", ""); + await app.land("addComment:3,ann,on it", { author: "ann", text: "on it" }); + await app.landIfAsked("fetchComments:3", [{ author: "dana", text: "seen it too" }, { author: "ann", text: "on it" }]); + await app.rows("comment", ["dana: seen it too", "ann: on it"]); + } }, + { name: "help on open: nothing selected, nothing asked", async run(app) { + await open(app, "help"); + await app.see("item", "pick a result"); + if ((await app.asked()).some(k => /^(search|fetchItem):/.test(k))) throw new Error(`asked ${await app.asked()} on open`); + } }, + { name: "one character is too short and asks nothing", async run(app) { + await open(app, "help"); + await app.type("query", "a"); + await app.see("results", "too short: a"); + await app.notAsked("search:a"); + } }, + { name: "a search shows searching…, then its results", async run(app) { + await open(app, "help"); + await app.type("query", "ap"); + await app.inAir("search:ap"); + await app.see("results", "searching…"); + await app.land("search:ap", AP); + await app.see("results", "2 results for ap"); + await app.notSee("results", "searching…"); + await app.rows("hit", ["apple", "apricot"]); + } }, + { name: "selecting a hit loads its detail", async run(app) { + await open(app, "help"); + await searched(app, "app", APP); + await app.clickText("hit", "applesauce"); + await app.rows("hit", ["apple", "> applesauce", "apple pie"]); + await app.see("item", "loading…"); + await app.land("fetchItem:3", ITEM3); + await app.see("item", "applesauce", "stewed apples"); + } }, + { name: "loads, then shows the saved line and the form", async run(app) { + await open(app, "settings"); + await app.see("load", "Loading settings…"); + await settingsLoaded(app); + await app.eq("saved", "Saved: Ann · light · compact"); + await app.hasValue("name", "Ann"); + await app.eq("name-count", "3/20"); + await app.eq("preview", "Preview: light / compact"); + } }, + { name: "validates the name", async run(app) { + await open(app, "settings"); + await settingsLoaded(app); + await app.type("name", ""); + await app.see("name-error", "Display name is required"); + await app.type("name", "Bartholomew the Magnificent"); + await app.see("name-error", "Display name must be at most 20 characters, not 27"); + await app.eq("name-count", "27/20"); + await app.type("name", "Bea"); + await app.notSee("name-error", "Display name"); + } }, + { name: "a save shows at once, then lands", async run(app) { + await open(app, "settings"); + await saving(app, "Bea", "dark"); + await app.eq("saved", "Saved: Bea · dark · compact (saving…)"); + await app.landOnly("saveSettings", { name: "Bea", theme: "dark", density: "compact" }); + await app.eq("saved", "Saved: Bea · dark · compact"); + } }, + { name: "a failed load says so", async run(app) { + await open(app, "settings"); + await app.fail("loadSettings:", "network down"); + await app.see("load", "Could not load settings: network down"); + } }, + { name: "tabs: settings, then back to issues, starts the issues page again", async run(app) { + await selected(app); + await open(app, "settings"); + await app.see("load", "Loading settings…"); + await app.notSee("list", "Crash on save"); + await open(app, "issues"); + await app.see("list", "loading open issues"); + await app.see("detail", "select an issue"); + } }, + { name: "selecting an issue loads its labels", async run(app) { + await selected(app); + await app.see("labels", "loading labels"); + await app.landIfAsked("fetchComments:3", C3); + await app.land("fetchLabels:3", L3); + await app.rows("label", ["bug", "ui"]); + } }, + { name: "adding a label shows it at once and keeps it when accepted", async run(app) { + await withLabels(app); + await addLabel(app, "crash"); + await app.inAir("addLabel:3,crash"); + await app.rows("label", ["bug", "ui", "crash"]); + await app.hasValue("label-input", ""); + await app.land("addLabel:3,crash", [...L3, "crash"]); + await app.landIfAsked("fetchLabels:3", [...L3, "crash"]); + await app.rows("label", ["bug", "ui", "crash"]); + } }, + { name: "removing a label takes it out at once", async run(app) { + await withLabels(app); + await removeLabel(app, "bug"); + await app.inAir("removeLabel:3,bug"); + await app.rows("label", ["ui"]); + await app.land("removeLabel:3,bug", ["ui"]); + await app.landIfAsked("fetchLabels:3", ["ui"]); + await app.rows("label", ["ui"]); + } }, + { name: "the issues tab shows the open count once the open issues load", async run(app) { + await app.eq("tab-issues", "Issues"); + await loaded(app); + await app.eq("tab-issues", "Issues (3)"); + } }, + { name: "closing takes one off the count at once and keeps it off when accepted", async run(app) { + await selected(app); + await app.click("close"); + await app.eq("tab-issues", "Issues (2)"); + await app.land("closeIssue:3", { id: 3 }); + await app.landIfAsked("fetchIssues:open", openWithout(3)); + await app.eq("tab-issues", "Issues (2)"); + } }, + { name: "a selected issue shows its related help", async run(app) { + await selected(app); + await app.see("related", "finding related help"); + await app.land("search:Crash on save", REL3); + await app.rows("related-hit", ["saving files", "crash reports", "autosave"]); + } }, + { name: "opening a related hit shows it on the help page", async run(app) { + await withRelated(app); + await app.clickText("related-hit", "crash reports"); + await app.hasValue("query", "Crash on save"); + await app.landIfAsked("search:Crash on save", REL3); + await app.see("results", "4 results for Crash on save"); + await app.rows("hit", ["saving files", "> crash reports", "autosave", "backups"]); + await app.see("item", "loading…"); + await app.land("fetchItem:8", ITEM8); + await app.see("item", "crash reports", "send us the log"); + } }, + { name: "rows: each row has an assign and a close button, and its text is unchanged", async run(app) { + await loaded(app); + await app.rows("issue", OPEN_ROWS); + await app.rows("row-assign", ["assign to guest", "assign to guest", "assign to guest"]); + await app.rows("row-close", ["close", "close", "close"]); + await app.type("user", "ann"); + await app.rows("row-assign", ["assign to ann", "assign to ann", "assign to ann"]); + } }, + { name: "rows: a row assign shows the assignee at once and keeps it, without selecting the issue", async run(app) { + await loaded(app); + await app.type("user", "ann"); + await rowAssign(app, 1); + await app.inAir("assign:3,ann"); + await app.see("list", "#3 Crash on save (ann)"); + await app.see("detail", "select an issue"); + await app.notAsked("fetchIssue:3"); + await app.land("assign:3,ann", { id: 3, assignee: "ann" }); + await app.see("list", "#3 Crash on save (ann)"); + } }, + { name: "rows: a row close removes the row at once and keeps it out", async run(app) { + await loaded(app); + await rowClose(app, 1); + await app.inAir("closeIssue:3"); + await app.rows("issue", rowsWithout(3)); + await app.eq("tab-issues", "Issues (2)"); + await app.land("closeIssue:3", { id: 3 }); + await app.rows("issue", rowsWithout(3)); + await app.eq("tab-issues", "Issues (2)"); + } }, + { name: "rows: closed issues have no row close button", async run(app) { + await loaded(app); + await app.click("filter-closed"); + await app.land("fetchIssues:closed", CLOSED); + await app.rows("row-assign", ["assign to guest"]); + await app.rows("row-close", []); + } }, +]; diff --git a/eval/tasks/comments/hidden-overlap.mjs b/eval/tasks/comments/hidden-overlap.mjs index 09ed58c..57098c7 100644 --- a/eval/tasks/comments/hidden-overlap.mjs +++ b/eval/tasks/comments/hidden-overlap.mjs @@ -24,10 +24,14 @@ export default [ await withComments(app); await app.type("user", "ann"); await post(app, "on it"); - await app.clickText("issue", "#1 "); await app.land("fetchIssue:1", D1); await app.land("fetchComments:1", C1); - await app.notSee("comments", "on it"); + await app.clickText("issue", "#1 "); await app.landIfAsked("fetchIssue:1", D1); await app.landIfAsked("fetchComments:1", C1); + // a whole-list guess may hold the switch until the post settles (solidjs/solid#4337: the selection change joins + // the guess's action); what must not show is the post under #1 + if ((await app.text("comments") ?? "").includes("on it")) await app.see("detail", "Crash on save"); await app.land("addComment:3,ann,on it", { author: "ann", text: "on it" }); + await app.landIfAsked("fetchIssue:1", D1); await app.landIfAsked("fetchComments:1", C1); await app.landIfAsked("fetchComments:3", after({ author: "ann", text: "on it" })); + await app.see("detail", "Login fails"); await app.notSee("comments", "on it"); } }, { name: "comments that load while a post is out keep the post", async run(app) { diff --git a/eval/tasks/count/BASE.md b/eval/tasks/count/BASE.md new file mode 100644 index 0000000..e6a26eb --- /dev/null +++ b/eval/tasks/count/BASE.md @@ -0,0 +1,143 @@ +# Issue desk + +Build an issue desk: three pages behind a row of tabs. The issues page is an issue tracker with assigning, renaming, +closing and comments; the help page searches a help catalogue; the settings page edits the user's settings. + +## The API (`src/api.js`, given; do not change it) + +Issues: + +- `fetchIssues(filter: "open" | "closed"): Promise<{ id: number; title: string; assignee: string | null }[]>`. May + reject with an `Error`. +- `fetchIssue(id: number): Promise<{ id: number; title: string; body: string } | null>`: `null` when there is no such + issue. May reject with an `Error`. +- `assign(id: number, user: string): Promise<{ id: number; assignee: string }>`: assigns the issue on the server. May + reject with an `Error` (the server declined). +- `renameIssue(id: number, title: string): Promise<{ id: number; title: string }>`: renames the issue on the server. + May reject with an `Error` (the server declined). +- `closeIssue(id: number): Promise<{ id: number }>`: closes the issue on the server. May reject with an `Error` (the + server declined). +- `fetchComments(id: number): Promise<{ author: string; text: string }[]>`: the issue's comments, oldest first. May + reject with an `Error`. +- `addComment(id: number, author: string, text: string): Promise<{ author: string; text: string }>`: adds a comment + on the server. May reject with an `Error` (the server declined). + +Help: + +- `search(q: string): Promise<{ id: number; name: string }[]>`: the hits for `q`, in order. It may reject with an + `Error`. +- `fetchItem(id: number): Promise<{ id: number; name: string; blurb: string } | null>`: one item, or `null` when there + is no item with that id. It may reject with an `Error`. + +Settings: + +- `loadSettings(): Promise<{ name: string; theme: "light" | "dark"; density: "comfortable" | "compact" }>`. May + reject with an `Error`. +- `saveSettings(s: { name: string; theme: "light" | "dark"; density: "comfortable" | "compact" }): Promise<same shape>`: + answers the settings as saved. May reject with an `Error`. + +## What the app must do + +Elements the tests look for carry these `data-testid`s. Text in quotes must appear exactly as written. + +### Tabs + +1. Three buttons, `tab-issues`, `tab-help` and `tab-settings`, always on screen. The app opens on the issues page. +2. Only the open page is on screen. Leaving a page discards it: opening it again starts it as it starts when the app + opens (it loads again, nothing selected, inputs at their starting values). A request a discarded page made may + still answer; it must not show on, or change, any page. + +### The issues page + +1. **Current user.** A text input, `user`, holding the current user's name. It starts as `guest`. +2. **Filter.** Two buttons, `filter-open` and `filter-closed`. The filter starts as `open`. +3. **List**, `list`, of the issues for the current filter, from `fetchIssues(filter)`. + - While they load (when the page opens or the filter changes): `loading <filter> issues` (`loading closed issues`). + - Then one row per issue, `data-testid="issue"`, reading `#<id> <title> (<assignee>)`, or `(unassigned)` when the + assignee is `null`: `#3 Crash on save (unassigned)`. + - If it fails: `list error: <message>`. +4. **Detail**, `detail`. + - Nothing selected: `select an issue`. Clicking a row selects its issue. + - The selected issue comes from `fetchIssue(id)`. While it loads: `loading #<id>`. + - An issue: `#<id> <title>` and its body. + - `null`: `detail error: issue #<id> not found`. A failure: `detail error: <message>`. + - A button `retry` (`data-testid="retry"`) calls `fetchIssue(id)` again and shows its answer. +5. **Assign.** While an issue is selected, the detail has a button `assign` (`data-testid="assign"`) reading + `assign to <current user>`. Clicking it: + - shows the current user as the issue's assignee in its list row straight away; + - calls `assign(id, user)`; + - if the server declines, the row goes back to the assignee it had before, and the detail shows the server's + message (`declined`); + - if the server accepts, the row keeps the new assignee. + - An assignment error is cleared when a new assignment starts or another issue is selected. +6. **Rename.** While an issue is selected, the detail has a text input `title-input`, empty when the issue is + selected, and a button `rename` (`data-testid="rename"`). Clicking `rename` while `title-input` holds a title: + - shows the new title in the issue's list row and in the detail's heading (`#<id> <title>`) straight away; + - calls `renameIssue(id, title)`; + - if the server declines, the row and the heading go back to the title they had before, and the detail shows + `rename error: <message>` (`rename error: declined`); + - if the server accepts, the row and the heading keep the new title. + - A rename error is cleared when a new rename starts or another issue is selected. +7. **Close.** While an issue is selected, the detail has a button `close` (`data-testid="close"`). Clicking it: + - removes the issue's row from the list of open issues straight away (the detail keeps showing the issue); + - calls `closeIssue(id)`; + - if the server declines, the row comes back where it was, and the detail shows `close error: <message>` + (`close error: declined`); + - if the server accepts, the row stays out of the open issues. + - A close error is cleared when a new close starts or another issue is selected. +8. **Comments.** While an issue is selected, the detail has an element `comments` with that issue's comments, from + `fetchComments(id)`. + - While they load: `loading comments`. + - Then one row per comment, `data-testid="comment"`, reading `<author>: <text>` (`dana: seen it too`). + - If it fails: `comments error: <message>`. +9. **Posting.** The detail has a text input `comment-input` and a button `post` (`data-testid="post"`). Clicking + `post` while `comment-input` holds text: + - adds the comment as the last row straight away, reading `<current user>: <text> (sending…)`, and empties the + input; + - calls `addComment(id, user, text)`; + - if the server declines, the row goes away, and the detail shows `post error: <message>` (`post error: declined`); + - if the server accepts, the row stays and reads `<current user>: <text>`. + - A post error is cleared when a new post starts or another issue is selected. + +### The help page + +1. A text input, `query`. What the user types is the query. +2. A results area, `results`. + - Empty query: the results area shows nothing, and nothing is asked. + - A query of one character: the results area shows `too short: <q>` (for `a`: `too short: a`). Nothing is asked. + - Two characters or more: call `search(q)` and show `searching…` until the results arrive. Results already on + screen stay there until the new ones replace them. + - The results: a line `<n> results for <q>` (`2 results for ap`) and one list item per hit, `data-testid="hit"`, + whose text is the hit's name. + - If the search fails: `search failed: <message>`. +3. Clicking a hit selects it. The selected hit's text starts with `> ` (`> apple pie`); the others have no prefix. +4. A help pane, `item`. + - Nothing selected: `pick a result`. + - When a hit is selected, call `fetchItem(id)` and show `loading…` until it answers. + - The answer is an item: the pane shows its name and its blurb. + - The answer is `null`: `item <id> not found`. + - It fails: `failed: <message>` and a button `retry-item` (`data-testid="retry-item"`) that calls `fetchItem(id)` + again and shows its answer. + +### The settings page + +1. **Loading.** Call `loadSettings()` when the page opens. Until it answers, an element `load` shows + `Loading settings…`. If it fails, `load` shows `Could not load settings: <message>` and a button `retry-load` + calls `loadSettings()` again and shows the settings it answers. +2. **Saved line**, `saved`, once the settings have loaded: `Saved: <name> · <theme> · <density>` + (`Saved: Ann · light · compact`). +3. **The draft.** Once loaded, a form is filled from the loaded settings and then belongs to the user: + - an input `name` (the display name), a counter `name-count` reading `<length>/20`; + - `name-error`, shown only when the name is invalid: `Display name is required` when empty, + `Display name must be at most 20 characters, not <length>` when longer than 20; + - buttons `theme-light` and `theme-dark` that set the draft's theme; a button `density-toggle` that switches the + draft's density between `comfortable` and `compact`; + - `preview` reading `Preview: <draft theme> / <draft density>`. +4. **Save**, a button `save`. + - With an invalid name it does nothing (nothing is asked). + - Otherwise it calls `saveSettings(draft)`. Straight away the saved line shows the values being saved followed by + ` (saving…)` (`Saved: Bea · dark · compact (saving…)`), and any previous save error disappears. + - When the save succeeds, the saved line shows the saved settings, without `(saving…)`. + - When it fails, the saved line goes back to what was saved before, without `(saving…)`, and `save-error` shows + `Save failed: <message>`. + - Saving never changes the draft. diff --git a/eval/tasks/count/api.d.rx b/eval/tasks/count/api.d.rx new file mode 100644 index 0000000..25e52a8 --- /dev/null +++ b/eval/tasks/count/api.d.rx @@ -0,0 +1,19 @@ +// src/api.js, as .rx sees it. fetchIssues, fetchIssue, fetchComments, search, fetchItem and loadSettings are loaders +// (safe to ask from an equation); assign, renameIssue, closeIssue, addComment and saveSettings write. +export type Issue = { id: number; title: string; assignee: string | null } +export type IssueDetail = { id: number; title: string; body: string } +export pure fn fetchIssues(filter: string): Promise<Issue[]> +export pure fn fetchIssue(id: number): Promise<IssueDetail | null> +export fn assign(id: number, user: string): Promise<{ id: number; assignee: string }> +export fn renameIssue(id: number, title: string): Promise<{ id: number; title: string }> +export fn closeIssue(id: number): Promise<{ id: number }> +export type Comment = { author: string; text: string } +export pure fn fetchComments(id: number): Promise<Comment[]> +export fn addComment(id: number, author: string, text: string): Promise<Comment> +export type Hit = { id: number; name: string } +export type Item = { id: number; name: string; blurb: string } +export pure fn search(q: string): Promise<Hit[]> +export pure fn fetchItem(id: number): Promise<Item | null> +export type Settings = { name: string; theme: string; density: string } +export pure fn loadSettings(): Promise<Settings> +export fn saveSettings(s: Settings): Promise<Settings> diff --git a/eval/tasks/count/api.d.ts b/eval/tasks/count/api.d.ts new file mode 100644 index 0000000..2548c4d --- /dev/null +++ b/eval/tasks/count/api.d.ts @@ -0,0 +1,19 @@ +export type Issue = { id: number; title: string; assignee: string | null }; +export type IssueDetail = { id: number; title: string; body: string }; +export function fetchIssues(filter: "open" | "closed"): Promise<Issue[]>; +export function fetchIssue(id: number): Promise<IssueDetail | null>; +export function assign(id: number, user: string): Promise<{ id: number; assignee: string }>; +export function renameIssue(id: number, title: string): Promise<{ id: number; title: string }>; +export function closeIssue(id: number): Promise<{ id: number }>; +export type Comment = { author: string; text: string }; +export function fetchComments(id: number): Promise<Comment[]>; +export function addComment(id: number, author: string, text: string): Promise<Comment>; +export type Hit = { id: number; name: string }; +export type Item = { id: number; name: string; blurb: string }; +export function search(q: string): Promise<Hit[]>; +export function fetchItem(id: number): Promise<Item | null>; +export type Theme = "light" | "dark"; +export type Density = "comfortable" | "compact"; +export type Settings = { name: string; theme: Theme; density: Density }; +export function loadSettings(): Promise<Settings>; +export function saveSettings(s: Settings): Promise<Settings>; diff --git a/eval/tasks/count/api.js b/eval/tasks/count/api.js new file mode 100644 index 0000000..33a7a76 --- /dev/null +++ b/eval/tasks/count/api.js @@ -0,0 +1,13 @@ +import { flight } from "./flights.js"; + +export const fetchIssues = filter => flight("fetchIssues", [filter]); +export const fetchIssue = id => flight("fetchIssue", [id]); +export const assign = (id, user) => flight("assign", [id, user]); +export const renameIssue = (id, title) => flight("renameIssue", [id, title]); +export const closeIssue = id => flight("closeIssue", [id]); +export const fetchComments = id => flight("fetchComments", [id]); +export const addComment = (id, author, text) => flight("addComment", [id, author, text]); +export const search = q => flight("search", [q]); +export const fetchItem = id => flight("fetchItem", [id]); +export const loadSettings = () => flight("loadSettings", []); +export const saveSettings = s => flight("saveSettings", [s]); diff --git a/eval/tasks/count/brief-plain.md b/eval/tasks/count/brief-plain.md new file mode 100644 index 0000000..5d2ae74 --- /dev/null +++ b/eval/tasks/count/brief-plain.md @@ -0,0 +1,21 @@ +# Change request: open-issue count + +The app in `src/` is an issue desk that already works. `BASE.md` is the brief it was built to. Keep everything it +does, and show how many issues are open on the issues tab. + +## The API (`src/api.js`, given; do not change it) + +Everything in `BASE.md`. Nothing new. + +## What to add + +Elements the tests look for carry these `data-testid`s. Text in quotes must appear exactly as written. + +1. The `tab-issues` button reads `Issues (<n>)`, where `<n>` is the number of rows in the list of open issues + (`Issues (3)`). It shows on every page. Until the open issues have loaded once, it reads `Issues`. +2. The count follows the list of open issues as the issues page shows it: whenever the open issues load again (the + issues page opening again, or its filter switching back to open), the count follows the new answer. While the + list shows closed issues, is loading, or failed, and while another page is open, the count keeps the last number + it showed. +3. **Closing.** Clicking `close` on an open issue takes one off the count straight away, as its row leaves the + list. If the server declines, the count goes back; if it accepts, the count stays down. diff --git a/eval/tasks/count/fixtures.mjs b/eval/tasks/count/fixtures.mjs new file mode 100644 index 0000000..1681b71 --- /dev/null +++ b/eval/tasks/count/fixtures.mjs @@ -0,0 +1,43 @@ +// Issues +export const OPEN = [{ id: 1, title: "Login fails", assignee: null }, { id: 3, title: "Crash on save", assignee: null }, { id: 4, title: "Typo in footer", assignee: "dana" }]; +export const CLOSED = [{ id: 2, title: "Old bug", assignee: "eve" }]; +export const OPEN_ROWS = ["#1 Login fails (unassigned)", "#3 Crash on save (unassigned)", "#4 Typo in footer (dana)"]; +export const D1 = { id: 1, title: "Login fails", body: "on Safari only" }; +export const D3 = { id: 3, title: "Crash on save", body: "stack trace attached" }; +export const C3 = [{ author: "dana", text: "seen it too" }]; +export const C1 = []; +/** the open issues loaded */ +export async function loaded(app) { await app.land("fetchIssues:open", OPEN); } +/** the open issues loaded and issue `id` selected with its detail */ +export async function selected(app, id = 3, detail = D3) { await loaded(app); await app.clickText("issue", `#${id} `); await app.land(`fetchIssue:${id}`, detail); } +/** issue 3 selected with its detail and its comments */ +export async function withComments(app) { await selected(app); await app.land("fetchComments:3", C3); } +/** type `title` into the detail's title input and click rename */ +export async function rename(app, title) { await app.type("title-input", title); await app.click("rename"); } +/** type `text` into the comment input and click post */ +export async function post(app, text) { await app.type("comment-input", text); await app.click("post"); } +export const openWithout = id => OPEN.filter(i => i.id !== id); +export const rowsWithout = id => OPEN_ROWS.filter(r => !r.startsWith(`#${id} `)); + +// Help +export const AP = [{ id: 1, name: "apple" }, { id: 2, name: "apricot" }]; +export const APP = [{ id: 1, name: "apple" }, { id: 3, name: "applesauce" }, { id: 4, name: "apple pie" }]; +export const ITEM3 = { id: 3, name: "applesauce", blurb: "stewed apples" }; +export const ITEM4 = { id: 4, name: "apple pie", blurb: "baked with cinnamon" }; +/** type a query and land its answer */ +export async function searched(app, q, hits) { await app.type("query", q); await app.land(`search:${q}`, hits); } + +// Settings +export const ANN = { name: "Ann", theme: "light", density: "compact" }; +export async function settingsLoaded(app) { await app.land("loadSettings:", ANN); } +/** loaded, the name typed, the theme picked, then saved: returns the request's key */ +export async function saving(app, name, theme) { + await settingsLoaded(app); + await app.type("name", name); + if (theme) await app.click(`theme-${theme}`); + await app.click("save"); + return app.only("saveSettings"); +} + +/** open the help or settings tab first */ +export async function open(app, page) { await app.click(`tab-${page}`); } diff --git a/eval/tasks/count/hidden-overlap.mjs b/eval/tasks/count/hidden-overlap.mjs new file mode 100644 index 0000000..c8d4d8c --- /dev/null +++ b/eval/tasks/count/hidden-overlap.mjs @@ -0,0 +1,43 @@ +// Round 5 hidden tests: the count under overlapping closes and loads, and the desk's overlap tests as regressions. +// Never shown to the agent. +import base from "../desk/hidden-overlap.mjs"; +import { D1, OPEN, loaded, openWithout, selected } from "./fixtures.mjs"; + +export default [ + ...base.map(t => ({ ...t, name: `base: ${t.name}` })), + { name: "two closes at once: a declined one leaves the other off the count", async run(app) { + await selected(app); + await app.click("close"); + await app.clickText("issue", "#1 "); await app.landIfAsked("fetchIssue:1", D1); + await app.click("close"); + await app.eq("tab-issues", "Issues (1)"); + await app.fail("closeIssue:3", "declined"); + // the declined close may stay off until the other settles (two guesses on one list are one change, H-11) + await app.land("closeIssue:1", { id: 1 }); + await app.landIfAsked("fetchIssues:open", openWithout(1)); + await app.eq("tab-issues", "Issues (2)"); + } }, + { name: "open issues that load while a close is out keep it off the count", async run(app) { + await selected(app); + await app.click("close"); + await app.click("filter-closed"); + await app.landIfAsked("fetchIssues:closed", []); + await app.click("filter-open"); + await app.landIfAsked("fetchIssues:open", OPEN); + await app.eq("tab-issues", "Issues (2)"); + await app.fail("closeIssue:3", "declined"); + await app.landIfAsked("fetchIssues:open", OPEN); + await app.eq("tab-issues", "Issues (3)"); + } }, + { name: "a close that answers after the issues page was left still shows in the count", async run(app) { + await selected(app); + await app.click("close"); + await app.click("tab-help"); + await app.eq("tab-issues", "Issues (2)"); + await app.land("closeIssue:3", { id: 3 }); + await app.eq("tab-issues", "Issues (2)"); + await app.click("tab-issues"); + await app.land("fetchIssues:open", openWithout(3)); + await app.eq("tab-issues", "Issues (2)"); + } }, +]; diff --git a/eval/tasks/count/hidden.mjs b/eval/tasks/count/hidden.mjs new file mode 100644 index 0000000..47a0739 --- /dev/null +++ b/eval/tasks/count/hidden.mjs @@ -0,0 +1,53 @@ +// Round 5 hidden tests: the change's own behaviour, and the desk's hidden tests as regressions. Never shown to the +// agent. +import base from "../desk/hidden.mjs"; +import { CLOSED, OPEN, loaded, open, selected } from "./fixtures.mjs"; + +export default [ + ...base.map(t => ({ ...t, name: `base: ${t.name}` })), + { name: "a declined close puts the count back", async run(app) { + await selected(app); + await app.click("close"); + await app.eq("tab-issues", "Issues (2)"); + await app.fail("closeIssue:3", "declined"); + await app.eq("tab-issues", "Issues (3)"); + } }, + { name: "the count stays on the other pages", async run(app) { + await loaded(app); + await open(app, "help"); + await app.eq("tab-issues", "Issues (3)"); + await open(app, "settings"); + await app.eq("tab-issues", "Issues (3)"); + } }, + { name: "the count follows the open issues when the issues page opens again", async run(app) { + await loaded(app); + await open(app, "help"); + await open(app, "issues"); + await app.eq("tab-issues", "Issues (3)"); + await app.land("fetchIssues:open", OPEN.slice(0, 2)); + await app.eq("tab-issues", "Issues (2)"); + } }, + { name: "the count keeps its number while closed issues show, and follows open ones again", async run(app) { + await loaded(app); + await app.click("filter-closed"); + await app.land("fetchIssues:closed", CLOSED); + await app.eq("tab-issues", "Issues (3)"); + await app.click("filter-open"); + await app.landIfAsked("fetchIssues:open", [...OPEN, { id: 5, title: "Slow start", assignee: null }]); + await app.eq("tab-issues", "Issues (4)"); + } }, + { name: "a failed load keeps the last count", async run(app) { + await loaded(app); + await open(app, "help"); + await open(app, "issues"); + await app.fail("fetchIssues:open", "offline"); + await app.see("list", "list error: offline"); + await app.eq("tab-issues", "Issues (3)"); + } }, + { name: "no count before the open issues have loaded once", async run(app) { + await app.fail("fetchIssues:open", "offline"); + await app.eq("tab-issues", "Issues"); + await open(app, "help"); + await app.eq("tab-issues", "Issues"); + } }, +]; diff --git a/eval/tasks/count/visible.mjs b/eval/tasks/count/visible.mjs new file mode 100644 index 0000000..75ea56b --- /dev/null +++ b/eval/tasks/count/visible.mjs @@ -0,0 +1,163 @@ +// Visible tests: the agent sees and runs these. The desk's, then the change's. +import { AP, APP, CLOSED, D3, ITEM3, OPEN, OPEN_ROWS, loaded, open, openWithout, post, rename, rowsWithout, saving, searched, selected, settingsLoaded, withComments } from "./fixtures.mjs"; + +export default [ + { name: "on load: the open issues load", async run(app) { + await app.see("list", "loading open issues"); + await app.see("detail", "select an issue"); + await loaded(app); + await app.rows("issue", OPEN_ROWS); + } }, + { name: "switching the filter loads the closed issues", async run(app) { + await loaded(app); + await app.click("filter-closed"); + await app.see("list", "loading closed issues"); + await app.land("fetchIssues:closed", CLOSED); + await app.rows("issue", ["#2 Old bug (eve)"]); + } }, + { name: "selecting an issue loads its detail", async run(app) { + await loaded(app); + await app.clickText("issue", "#3 "); + await app.see("detail", "loading #3"); + await app.land("fetchIssue:3", D3); + await app.see("detail", "#3 Crash on save", "stack trace attached"); + } }, + { name: "assigning shows the new assignee at once", async run(app) { + await selected(app); + await app.type("user", "ann"); + await app.see("assign", "assign to ann"); + await app.click("assign"); + await app.inAir("assign:3,ann"); + await app.see("list", "#3 Crash on save (ann)"); + await app.land("assign:3,ann", { id: 3, assignee: "ann" }); + await app.see("list", "#3 Crash on save (ann)"); + } }, + { name: "renaming shows the new title at once in the row and the heading", async run(app) { + await selected(app); + await rename(app, "Crash at boot"); + await app.inAir("renameIssue:3,Crash at boot"); + await app.see("list", "#3 Crash at boot (unassigned)"); + await app.see("detail", "#3 Crash at boot"); + } }, + { name: "an accepted rename keeps the new title", async run(app) { + await selected(app); + await rename(app, "Crash at boot"); + await app.land("renameIssue:3,Crash at boot", { id: 3, title: "Crash at boot" }); + await app.see("list", "#3 Crash at boot (unassigned)"); + await app.see("detail", "#3 Crash at boot"); + } }, + { name: "closing removes the row at once and keeps the detail", async run(app) { + await selected(app); + await app.click("close"); + await app.inAir("closeIssue:3"); + await app.rows("issue", rowsWithout(3)); + await app.see("detail", "#3 Crash on save"); + } }, + { name: "an accepted close keeps the row out", async run(app) { + await selected(app); + await app.click("close"); + await app.land("closeIssue:3", { id: 3 }); + await app.rows("issue", rowsWithout(3)); + } }, + { name: "selecting an issue loads its comments", async run(app) { + await selected(app); + await app.see("comments", "loading comments"); + await app.land("fetchComments:3", [{ author: "dana", text: "seen it too" }]); + await app.rows("comment", ["dana: seen it too"]); + } }, + { name: "posting shows the comment at once and keeps it when accepted", async run(app) { + await withComments(app); + await app.type("user", "ann"); + await post(app, "on it"); + await app.inAir("addComment:3,ann,on it"); + await app.rows("comment", ["dana: seen it too", "ann: on it (sending…)"]); + await app.hasValue("comment-input", ""); + await app.land("addComment:3,ann,on it", { author: "ann", text: "on it" }); + await app.landIfAsked("fetchComments:3", [{ author: "dana", text: "seen it too" }, { author: "ann", text: "on it" }]); + await app.rows("comment", ["dana: seen it too", "ann: on it"]); + } }, + { name: "help on open: nothing selected, nothing asked", async run(app) { + await open(app, "help"); + await app.see("item", "pick a result"); + if ((await app.asked()).some(k => /^(search|fetchItem):/.test(k))) throw new Error(`asked ${await app.asked()} on open`); + } }, + { name: "one character is too short and asks nothing", async run(app) { + await open(app, "help"); + await app.type("query", "a"); + await app.see("results", "too short: a"); + await app.notAsked("search:a"); + } }, + { name: "a search shows searching…, then its results", async run(app) { + await open(app, "help"); + await app.type("query", "ap"); + await app.inAir("search:ap"); + await app.see("results", "searching…"); + await app.land("search:ap", AP); + await app.see("results", "2 results for ap"); + await app.notSee("results", "searching…"); + await app.rows("hit", ["apple", "apricot"]); + } }, + { name: "selecting a hit loads its detail", async run(app) { + await open(app, "help"); + await searched(app, "app", APP); + await app.clickText("hit", "applesauce"); + await app.rows("hit", ["apple", "> applesauce", "apple pie"]); + await app.see("item", "loading…"); + await app.land("fetchItem:3", ITEM3); + await app.see("item", "applesauce", "stewed apples"); + } }, + { name: "loads, then shows the saved line and the form", async run(app) { + await open(app, "settings"); + await app.see("load", "Loading settings…"); + await settingsLoaded(app); + await app.eq("saved", "Saved: Ann · light · compact"); + await app.hasValue("name", "Ann"); + await app.eq("name-count", "3/20"); + await app.eq("preview", "Preview: light / compact"); + } }, + { name: "validates the name", async run(app) { + await open(app, "settings"); + await settingsLoaded(app); + await app.type("name", ""); + await app.see("name-error", "Display name is required"); + await app.type("name", "Bartholomew the Magnificent"); + await app.see("name-error", "Display name must be at most 20 characters, not 27"); + await app.eq("name-count", "27/20"); + await app.type("name", "Bea"); + await app.notSee("name-error", "Display name"); + } }, + { name: "a save shows at once, then lands", async run(app) { + await open(app, "settings"); + await saving(app, "Bea", "dark"); + await app.eq("saved", "Saved: Bea · dark · compact (saving…)"); + await app.landOnly("saveSettings", { name: "Bea", theme: "dark", density: "compact" }); + await app.eq("saved", "Saved: Bea · dark · compact"); + } }, + { name: "a failed load says so", async run(app) { + await open(app, "settings"); + await app.fail("loadSettings:", "network down"); + await app.see("load", "Could not load settings: network down"); + } }, + { name: "tabs: settings, then back to issues, starts the issues page again", async run(app) { + await selected(app); + await open(app, "settings"); + await app.see("load", "Loading settings…"); + await app.notSee("list", "Crash on save"); + await open(app, "issues"); + await app.see("list", "loading open issues"); + await app.see("detail", "select an issue"); + } }, + { name: "the issues tab shows the open count once the open issues load", async run(app) { + await app.eq("tab-issues", "Issues"); + await loaded(app); + await app.eq("tab-issues", "Issues (3)"); + } }, + { name: "closing takes one off the count at once and keeps it off when accepted", async run(app) { + await selected(app); + await app.click("close"); + await app.eq("tab-issues", "Issues (2)"); + await app.land("closeIssue:3", { id: 3 }); + await app.landIfAsked("fetchIssues:open", openWithout(3)); + await app.eq("tab-issues", "Issues (2)"); + } }, +]; diff --git a/eval/tasks/desk/api.d.rx b/eval/tasks/desk/api.d.rx new file mode 100644 index 0000000..25e52a8 --- /dev/null +++ b/eval/tasks/desk/api.d.rx @@ -0,0 +1,19 @@ +// src/api.js, as .rx sees it. fetchIssues, fetchIssue, fetchComments, search, fetchItem and loadSettings are loaders +// (safe to ask from an equation); assign, renameIssue, closeIssue, addComment and saveSettings write. +export type Issue = { id: number; title: string; assignee: string | null } +export type IssueDetail = { id: number; title: string; body: string } +export pure fn fetchIssues(filter: string): Promise<Issue[]> +export pure fn fetchIssue(id: number): Promise<IssueDetail | null> +export fn assign(id: number, user: string): Promise<{ id: number; assignee: string }> +export fn renameIssue(id: number, title: string): Promise<{ id: number; title: string }> +export fn closeIssue(id: number): Promise<{ id: number }> +export type Comment = { author: string; text: string } +export pure fn fetchComments(id: number): Promise<Comment[]> +export fn addComment(id: number, author: string, text: string): Promise<Comment> +export type Hit = { id: number; name: string } +export type Item = { id: number; name: string; blurb: string } +export pure fn search(q: string): Promise<Hit[]> +export pure fn fetchItem(id: number): Promise<Item | null> +export type Settings = { name: string; theme: string; density: string } +export pure fn loadSettings(): Promise<Settings> +export fn saveSettings(s: Settings): Promise<Settings> diff --git a/eval/tasks/desk/api.d.ts b/eval/tasks/desk/api.d.ts new file mode 100644 index 0000000..2548c4d --- /dev/null +++ b/eval/tasks/desk/api.d.ts @@ -0,0 +1,19 @@ +export type Issue = { id: number; title: string; assignee: string | null }; +export type IssueDetail = { id: number; title: string; body: string }; +export function fetchIssues(filter: "open" | "closed"): Promise<Issue[]>; +export function fetchIssue(id: number): Promise<IssueDetail | null>; +export function assign(id: number, user: string): Promise<{ id: number; assignee: string }>; +export function renameIssue(id: number, title: string): Promise<{ id: number; title: string }>; +export function closeIssue(id: number): Promise<{ id: number }>; +export type Comment = { author: string; text: string }; +export function fetchComments(id: number): Promise<Comment[]>; +export function addComment(id: number, author: string, text: string): Promise<Comment>; +export type Hit = { id: number; name: string }; +export type Item = { id: number; name: string; blurb: string }; +export function search(q: string): Promise<Hit[]>; +export function fetchItem(id: number): Promise<Item | null>; +export type Theme = "light" | "dark"; +export type Density = "comfortable" | "compact"; +export type Settings = { name: string; theme: Theme; density: Density }; +export function loadSettings(): Promise<Settings>; +export function saveSettings(s: Settings): Promise<Settings>; diff --git a/eval/tasks/desk/api.js b/eval/tasks/desk/api.js new file mode 100644 index 0000000..33a7a76 --- /dev/null +++ b/eval/tasks/desk/api.js @@ -0,0 +1,13 @@ +import { flight } from "./flights.js"; + +export const fetchIssues = filter => flight("fetchIssues", [filter]); +export const fetchIssue = id => flight("fetchIssue", [id]); +export const assign = (id, user) => flight("assign", [id, user]); +export const renameIssue = (id, title) => flight("renameIssue", [id, title]); +export const closeIssue = id => flight("closeIssue", [id]); +export const fetchComments = id => flight("fetchComments", [id]); +export const addComment = (id, author, text) => flight("addComment", [id, author, text]); +export const search = q => flight("search", [q]); +export const fetchItem = id => flight("fetchItem", [id]); +export const loadSettings = () => flight("loadSettings", []); +export const saveSettings = s => flight("saveSettings", [s]); diff --git a/eval/tasks/desk/brief-plain.md b/eval/tasks/desk/brief-plain.md new file mode 100644 index 0000000..e6a26eb --- /dev/null +++ b/eval/tasks/desk/brief-plain.md @@ -0,0 +1,143 @@ +# Issue desk + +Build an issue desk: three pages behind a row of tabs. The issues page is an issue tracker with assigning, renaming, +closing and comments; the help page searches a help catalogue; the settings page edits the user's settings. + +## The API (`src/api.js`, given; do not change it) + +Issues: + +- `fetchIssues(filter: "open" | "closed"): Promise<{ id: number; title: string; assignee: string | null }[]>`. May + reject with an `Error`. +- `fetchIssue(id: number): Promise<{ id: number; title: string; body: string } | null>`: `null` when there is no such + issue. May reject with an `Error`. +- `assign(id: number, user: string): Promise<{ id: number; assignee: string }>`: assigns the issue on the server. May + reject with an `Error` (the server declined). +- `renameIssue(id: number, title: string): Promise<{ id: number; title: string }>`: renames the issue on the server. + May reject with an `Error` (the server declined). +- `closeIssue(id: number): Promise<{ id: number }>`: closes the issue on the server. May reject with an `Error` (the + server declined). +- `fetchComments(id: number): Promise<{ author: string; text: string }[]>`: the issue's comments, oldest first. May + reject with an `Error`. +- `addComment(id: number, author: string, text: string): Promise<{ author: string; text: string }>`: adds a comment + on the server. May reject with an `Error` (the server declined). + +Help: + +- `search(q: string): Promise<{ id: number; name: string }[]>`: the hits for `q`, in order. It may reject with an + `Error`. +- `fetchItem(id: number): Promise<{ id: number; name: string; blurb: string } | null>`: one item, or `null` when there + is no item with that id. It may reject with an `Error`. + +Settings: + +- `loadSettings(): Promise<{ name: string; theme: "light" | "dark"; density: "comfortable" | "compact" }>`. May + reject with an `Error`. +- `saveSettings(s: { name: string; theme: "light" | "dark"; density: "comfortable" | "compact" }): Promise<same shape>`: + answers the settings as saved. May reject with an `Error`. + +## What the app must do + +Elements the tests look for carry these `data-testid`s. Text in quotes must appear exactly as written. + +### Tabs + +1. Three buttons, `tab-issues`, `tab-help` and `tab-settings`, always on screen. The app opens on the issues page. +2. Only the open page is on screen. Leaving a page discards it: opening it again starts it as it starts when the app + opens (it loads again, nothing selected, inputs at their starting values). A request a discarded page made may + still answer; it must not show on, or change, any page. + +### The issues page + +1. **Current user.** A text input, `user`, holding the current user's name. It starts as `guest`. +2. **Filter.** Two buttons, `filter-open` and `filter-closed`. The filter starts as `open`. +3. **List**, `list`, of the issues for the current filter, from `fetchIssues(filter)`. + - While they load (when the page opens or the filter changes): `loading <filter> issues` (`loading closed issues`). + - Then one row per issue, `data-testid="issue"`, reading `#<id> <title> (<assignee>)`, or `(unassigned)` when the + assignee is `null`: `#3 Crash on save (unassigned)`. + - If it fails: `list error: <message>`. +4. **Detail**, `detail`. + - Nothing selected: `select an issue`. Clicking a row selects its issue. + - The selected issue comes from `fetchIssue(id)`. While it loads: `loading #<id>`. + - An issue: `#<id> <title>` and its body. + - `null`: `detail error: issue #<id> not found`. A failure: `detail error: <message>`. + - A button `retry` (`data-testid="retry"`) calls `fetchIssue(id)` again and shows its answer. +5. **Assign.** While an issue is selected, the detail has a button `assign` (`data-testid="assign"`) reading + `assign to <current user>`. Clicking it: + - shows the current user as the issue's assignee in its list row straight away; + - calls `assign(id, user)`; + - if the server declines, the row goes back to the assignee it had before, and the detail shows the server's + message (`declined`); + - if the server accepts, the row keeps the new assignee. + - An assignment error is cleared when a new assignment starts or another issue is selected. +6. **Rename.** While an issue is selected, the detail has a text input `title-input`, empty when the issue is + selected, and a button `rename` (`data-testid="rename"`). Clicking `rename` while `title-input` holds a title: + - shows the new title in the issue's list row and in the detail's heading (`#<id> <title>`) straight away; + - calls `renameIssue(id, title)`; + - if the server declines, the row and the heading go back to the title they had before, and the detail shows + `rename error: <message>` (`rename error: declined`); + - if the server accepts, the row and the heading keep the new title. + - A rename error is cleared when a new rename starts or another issue is selected. +7. **Close.** While an issue is selected, the detail has a button `close` (`data-testid="close"`). Clicking it: + - removes the issue's row from the list of open issues straight away (the detail keeps showing the issue); + - calls `closeIssue(id)`; + - if the server declines, the row comes back where it was, and the detail shows `close error: <message>` + (`close error: declined`); + - if the server accepts, the row stays out of the open issues. + - A close error is cleared when a new close starts or another issue is selected. +8. **Comments.** While an issue is selected, the detail has an element `comments` with that issue's comments, from + `fetchComments(id)`. + - While they load: `loading comments`. + - Then one row per comment, `data-testid="comment"`, reading `<author>: <text>` (`dana: seen it too`). + - If it fails: `comments error: <message>`. +9. **Posting.** The detail has a text input `comment-input` and a button `post` (`data-testid="post"`). Clicking + `post` while `comment-input` holds text: + - adds the comment as the last row straight away, reading `<current user>: <text> (sending…)`, and empties the + input; + - calls `addComment(id, user, text)`; + - if the server declines, the row goes away, and the detail shows `post error: <message>` (`post error: declined`); + - if the server accepts, the row stays and reads `<current user>: <text>`. + - A post error is cleared when a new post starts or another issue is selected. + +### The help page + +1. A text input, `query`. What the user types is the query. +2. A results area, `results`. + - Empty query: the results area shows nothing, and nothing is asked. + - A query of one character: the results area shows `too short: <q>` (for `a`: `too short: a`). Nothing is asked. + - Two characters or more: call `search(q)` and show `searching…` until the results arrive. Results already on + screen stay there until the new ones replace them. + - The results: a line `<n> results for <q>` (`2 results for ap`) and one list item per hit, `data-testid="hit"`, + whose text is the hit's name. + - If the search fails: `search failed: <message>`. +3. Clicking a hit selects it. The selected hit's text starts with `> ` (`> apple pie`); the others have no prefix. +4. A help pane, `item`. + - Nothing selected: `pick a result`. + - When a hit is selected, call `fetchItem(id)` and show `loading…` until it answers. + - The answer is an item: the pane shows its name and its blurb. + - The answer is `null`: `item <id> not found`. + - It fails: `failed: <message>` and a button `retry-item` (`data-testid="retry-item"`) that calls `fetchItem(id)` + again and shows its answer. + +### The settings page + +1. **Loading.** Call `loadSettings()` when the page opens. Until it answers, an element `load` shows + `Loading settings…`. If it fails, `load` shows `Could not load settings: <message>` and a button `retry-load` + calls `loadSettings()` again and shows the settings it answers. +2. **Saved line**, `saved`, once the settings have loaded: `Saved: <name> · <theme> · <density>` + (`Saved: Ann · light · compact`). +3. **The draft.** Once loaded, a form is filled from the loaded settings and then belongs to the user: + - an input `name` (the display name), a counter `name-count` reading `<length>/20`; + - `name-error`, shown only when the name is invalid: `Display name is required` when empty, + `Display name must be at most 20 characters, not <length>` when longer than 20; + - buttons `theme-light` and `theme-dark` that set the draft's theme; a button `density-toggle` that switches the + draft's density between `comfortable` and `compact`; + - `preview` reading `Preview: <draft theme> / <draft density>`. +4. **Save**, a button `save`. + - With an invalid name it does nothing (nothing is asked). + - Otherwise it calls `saveSettings(draft)`. Straight away the saved line shows the values being saved followed by + ` (saving…)` (`Saved: Bea · dark · compact (saving…)`), and any previous save error disappears. + - When the save succeeds, the saved line shows the saved settings, without `(saving…)`. + - When it fails, the saved line goes back to what was saved before, without `(saving…)`, and `save-error` shows + `Save failed: <message>`. + - Saving never changes the draft. diff --git a/eval/tasks/desk/fixtures.mjs b/eval/tasks/desk/fixtures.mjs new file mode 100644 index 0000000..1681b71 --- /dev/null +++ b/eval/tasks/desk/fixtures.mjs @@ -0,0 +1,43 @@ +// Issues +export const OPEN = [{ id: 1, title: "Login fails", assignee: null }, { id: 3, title: "Crash on save", assignee: null }, { id: 4, title: "Typo in footer", assignee: "dana" }]; +export const CLOSED = [{ id: 2, title: "Old bug", assignee: "eve" }]; +export const OPEN_ROWS = ["#1 Login fails (unassigned)", "#3 Crash on save (unassigned)", "#4 Typo in footer (dana)"]; +export const D1 = { id: 1, title: "Login fails", body: "on Safari only" }; +export const D3 = { id: 3, title: "Crash on save", body: "stack trace attached" }; +export const C3 = [{ author: "dana", text: "seen it too" }]; +export const C1 = []; +/** the open issues loaded */ +export async function loaded(app) { await app.land("fetchIssues:open", OPEN); } +/** the open issues loaded and issue `id` selected with its detail */ +export async function selected(app, id = 3, detail = D3) { await loaded(app); await app.clickText("issue", `#${id} `); await app.land(`fetchIssue:${id}`, detail); } +/** issue 3 selected with its detail and its comments */ +export async function withComments(app) { await selected(app); await app.land("fetchComments:3", C3); } +/** type `title` into the detail's title input and click rename */ +export async function rename(app, title) { await app.type("title-input", title); await app.click("rename"); } +/** type `text` into the comment input and click post */ +export async function post(app, text) { await app.type("comment-input", text); await app.click("post"); } +export const openWithout = id => OPEN.filter(i => i.id !== id); +export const rowsWithout = id => OPEN_ROWS.filter(r => !r.startsWith(`#${id} `)); + +// Help +export const AP = [{ id: 1, name: "apple" }, { id: 2, name: "apricot" }]; +export const APP = [{ id: 1, name: "apple" }, { id: 3, name: "applesauce" }, { id: 4, name: "apple pie" }]; +export const ITEM3 = { id: 3, name: "applesauce", blurb: "stewed apples" }; +export const ITEM4 = { id: 4, name: "apple pie", blurb: "baked with cinnamon" }; +/** type a query and land its answer */ +export async function searched(app, q, hits) { await app.type("query", q); await app.land(`search:${q}`, hits); } + +// Settings +export const ANN = { name: "Ann", theme: "light", density: "compact" }; +export async function settingsLoaded(app) { await app.land("loadSettings:", ANN); } +/** loaded, the name typed, the theme picked, then saved: returns the request's key */ +export async function saving(app, name, theme) { + await settingsLoaded(app); + await app.type("name", name); + if (theme) await app.click(`theme-${theme}`); + await app.click("save"); + return app.only("saveSettings"); +} + +/** open the help or settings tab first */ +export async function open(app, page) { await app.click(`tab-${page}`); } diff --git a/eval/tasks/desk/hidden-overlap.mjs b/eval/tasks/desk/hidden-overlap.mjs new file mode 100644 index 0000000..b54e854 --- /dev/null +++ b/eval/tasks/desk/hidden-overlap.mjs @@ -0,0 +1,39 @@ +// Hidden overlap tests: the six tasks' overlap tests, each on its page, and a write that answers after its page +// was left. Never shown to the agent. +import tracker from "../tracker/hidden-overlap.mjs"; +import rename from "../rename/hidden-overlap.mjs"; +import close from "../close/hidden-overlap.mjs"; +import comments from "../comments/hidden-overlap.mjs"; +import search from "../search/hidden-overlap.mjs"; +import settings from "../settings/hidden-overlap.mjs"; +import { onPage, own } from "./pages.mjs"; +import { ANN, OPEN, open, saving, selected } from "./fixtures.mjs"; + +export default [ + ...tracker, ...own(rename), ...own(close), ...own(comments), + ...onPage("help", search).map(t => ({ ...t, name: `help: ${t.name}` })), + ...onPage("settings", settings).map(t => ({ ...t, name: `settings: ${t.name}` })), + { name: "tabs: an assignment that answers after the page was left changes nothing on its return", async run(app) { + await selected(app); + await app.type("user", "ann"); + await app.click("assign"); + await open(app, "help"); + await open(app, "issues"); + await app.fail("assign:3,ann", "declined"); + await app.see("list", "loading open issues"); + await app.land("fetchIssues:open", OPEN); + await app.see("list", "#3 Crash on save (unassigned)"); + await app.see("detail", "select an issue"); + } }, + { name: "tabs: a save that answers after the page was left shows the reloaded settings", async run(app) { + await open(app, "settings"); + await saving(app, "Bea", "dark"); + await open(app, "issues"); + await open(app, "settings"); + await app.landOnly("saveSettings", { name: "Bea", theme: "dark", density: "compact" }); + await app.see("load", "Loading settings…"); + await app.land("loadSettings:", { ...ANN, name: "Bea", theme: "dark" }); + await app.eq("saved", "Saved: Bea · dark · compact"); + await app.notSee("saved", "saving"); + } }, +]; diff --git a/eval/tasks/desk/hidden.mjs b/eval/tasks/desk/hidden.mjs new file mode 100644 index 0000000..6e14fee --- /dev/null +++ b/eval/tasks/desk/hidden.mjs @@ -0,0 +1,56 @@ +// Hidden tests: the six tasks' hidden tests, each on its page, and the tabs' own. Never shown to the agent. +import tracker from "../tracker/hidden.mjs"; +import rename from "../rename/hidden.mjs"; +import close from "../close/hidden.mjs"; +import comments from "../comments/hidden.mjs"; +import search from "../search/hidden.mjs"; +import settings from "../settings/hidden.mjs"; +import { onPage, own } from "./pages.mjs"; +import { ANN, AP, D3, OPEN, loaded, open, selected, searched, settingsLoaded } from "./fixtures.mjs"; + +export default [ + ...tracker, ...own(rename), ...own(close), ...own(comments), + ...onPage("help", search).map(t => ({ ...t, name: `help: ${t.name}` })), + ...onPage("settings", settings).map(t => ({ ...t, name: `settings: ${t.name}` })), + { name: "tabs: the help page starts again when reopened", async run(app) { + await open(app, "help"); + await searched(app, "ap", AP); + await open(app, "issues"); + await open(app, "help"); + await app.hasValue("query", ""); + await app.notSee("results", "apricot"); + await app.see("item", "pick a result"); + } }, + { name: "tabs: the settings page loads again when reopened, with a fresh draft", async run(app) { + await open(app, "settings"); + await settingsLoaded(app); + await app.type("name", "Bea"); + await open(app, "help"); + await open(app, "settings"); + await app.see("load", "Loading settings…"); + await app.askedTimes("loadSettings:", 2); + await app.land("loadSettings:", ANN); + await app.hasValue("name", "Ann"); + } }, + { name: "tabs: a detail answer for a page that was left does not show when it is reopened", async run(app) { + await loaded(app); + await app.clickText("issue", "#3 "); + await open(app, "help"); + await open(app, "issues"); + await app.land("fetchIssue:3", D3); + await loaded(app); + await app.see("detail", "select an issue"); + await app.notSee("detail", "Crash on save"); + } }, + { name: "tabs: the user resets to guest when the issues page is reopened", async run(app) { + await selected(app); + await app.type("user", "ann"); + await open(app, "settings"); + await open(app, "issues"); + await loaded(app); + await app.hasValue("user", "guest"); + await app.clickText("issue", "#3 "); + await app.landIfAsked("fetchIssue:3", D3); + await app.see("assign", "assign to guest"); + } }, +]; diff --git a/eval/tasks/desk/pages.mjs b/eval/tasks/desk/pages.mjs new file mode 100644 index 0000000..403d671 --- /dev/null +++ b/eval/tasks/desk/pages.mjs @@ -0,0 +1,27 @@ +// Runs another task's tests on one page of the desk: opens the page's tab first, and for the help page maps the +// search task's ids onto the desk's (`detail` -> `item`, `retry` -> `retry-item`) and hides the issues page's +// requests from `asked`/`pending` (the desk opens on the issues page, which asks for the open issues). +const ids = { detail: "item", retry: "retry-item" }; + +export function onPage(page, tests) { + return tests.map(t => ({ + ...t, + async run(app) { + if (page === "help") { + for (const m of ["text", "exists", "type", "value", "click", "clickText", "items"]) { + const f = app[m]; + app[m] = (id, ...rest) => f(ids[id] ?? id, ...rest); + } + for (const m of ["asked", "pending"]) { + const f = app[m]; + app[m] = async () => (await f()).filter(k => !k.startsWith("fetchIssues:")); + } + } + if (page !== "issues") await app.click(`tab-${page}`); + await t.run(app); + }, + })); +} + +/** a task's own tests, without the base tests it repeats */ +export const own = tests => tests.filter(t => !t.name.startsWith("base: ")); diff --git a/eval/tasks/desk/visible.mjs b/eval/tasks/desk/visible.mjs new file mode 100644 index 0000000..50128cd --- /dev/null +++ b/eval/tasks/desk/visible.mjs @@ -0,0 +1,150 @@ +// Visible tests: the agent sees and runs these. +import { AP, APP, CLOSED, D3, ITEM3, OPEN, OPEN_ROWS, loaded, open, post, rename, rowsWithout, saving, searched, selected, settingsLoaded, withComments } from "./fixtures.mjs"; + +export default [ + { name: "on load: the open issues load", async run(app) { + await app.see("list", "loading open issues"); + await app.see("detail", "select an issue"); + await loaded(app); + await app.rows("issue", OPEN_ROWS); + } }, + { name: "switching the filter loads the closed issues", async run(app) { + await loaded(app); + await app.click("filter-closed"); + await app.see("list", "loading closed issues"); + await app.land("fetchIssues:closed", CLOSED); + await app.rows("issue", ["#2 Old bug (eve)"]); + } }, + { name: "selecting an issue loads its detail", async run(app) { + await loaded(app); + await app.clickText("issue", "#3 "); + await app.see("detail", "loading #3"); + await app.land("fetchIssue:3", D3); + await app.see("detail", "#3 Crash on save", "stack trace attached"); + } }, + { name: "assigning shows the new assignee at once", async run(app) { + await selected(app); + await app.type("user", "ann"); + await app.see("assign", "assign to ann"); + await app.click("assign"); + await app.inAir("assign:3,ann"); + await app.see("list", "#3 Crash on save (ann)"); + await app.land("assign:3,ann", { id: 3, assignee: "ann" }); + await app.see("list", "#3 Crash on save (ann)"); + } }, + { name: "renaming shows the new title at once in the row and the heading", async run(app) { + await selected(app); + await rename(app, "Crash at boot"); + await app.inAir("renameIssue:3,Crash at boot"); + await app.see("list", "#3 Crash at boot (unassigned)"); + await app.see("detail", "#3 Crash at boot"); + } }, + { name: "an accepted rename keeps the new title", async run(app) { + await selected(app); + await rename(app, "Crash at boot"); + await app.land("renameIssue:3,Crash at boot", { id: 3, title: "Crash at boot" }); + await app.see("list", "#3 Crash at boot (unassigned)"); + await app.see("detail", "#3 Crash at boot"); + } }, + { name: "closing removes the row at once and keeps the detail", async run(app) { + await selected(app); + await app.click("close"); + await app.inAir("closeIssue:3"); + await app.rows("issue", rowsWithout(3)); + await app.see("detail", "#3 Crash on save"); + } }, + { name: "an accepted close keeps the row out", async run(app) { + await selected(app); + await app.click("close"); + await app.land("closeIssue:3", { id: 3 }); + await app.rows("issue", rowsWithout(3)); + } }, + { name: "selecting an issue loads its comments", async run(app) { + await selected(app); + await app.see("comments", "loading comments"); + await app.land("fetchComments:3", [{ author: "dana", text: "seen it too" }]); + await app.rows("comment", ["dana: seen it too"]); + } }, + { name: "posting shows the comment at once and keeps it when accepted", async run(app) { + await withComments(app); + await app.type("user", "ann"); + await post(app, "on it"); + await app.inAir("addComment:3,ann,on it"); + await app.rows("comment", ["dana: seen it too", "ann: on it (sending…)"]); + await app.hasValue("comment-input", ""); + await app.land("addComment:3,ann,on it", { author: "ann", text: "on it" }); + await app.landIfAsked("fetchComments:3", [{ author: "dana", text: "seen it too" }, { author: "ann", text: "on it" }]); + await app.rows("comment", ["dana: seen it too", "ann: on it"]); + } }, + { name: "help on open: nothing selected, nothing asked", async run(app) { + await open(app, "help"); + await app.see("item", "pick a result"); + if ((await app.asked()).some(k => /^(search|fetchItem):/.test(k))) throw new Error(`asked ${await app.asked()} on open`); + } }, + { name: "one character is too short and asks nothing", async run(app) { + await open(app, "help"); + await app.type("query", "a"); + await app.see("results", "too short: a"); + await app.notAsked("search:a"); + } }, + { name: "a search shows searching…, then its results", async run(app) { + await open(app, "help"); + await app.type("query", "ap"); + await app.inAir("search:ap"); + await app.see("results", "searching…"); + await app.land("search:ap", AP); + await app.see("results", "2 results for ap"); + await app.notSee("results", "searching…"); + await app.rows("hit", ["apple", "apricot"]); + } }, + { name: "selecting a hit loads its detail", async run(app) { + await open(app, "help"); + await searched(app, "app", APP); + await app.clickText("hit", "applesauce"); + await app.rows("hit", ["apple", "> applesauce", "apple pie"]); + await app.see("item", "loading…"); + await app.land("fetchItem:3", ITEM3); + await app.see("item", "applesauce", "stewed apples"); + } }, + { name: "loads, then shows the saved line and the form", async run(app) { + await open(app, "settings"); + await app.see("load", "Loading settings…"); + await settingsLoaded(app); + await app.eq("saved", "Saved: Ann · light · compact"); + await app.hasValue("name", "Ann"); + await app.eq("name-count", "3/20"); + await app.eq("preview", "Preview: light / compact"); + } }, + { name: "validates the name", async run(app) { + await open(app, "settings"); + await settingsLoaded(app); + await app.type("name", ""); + await app.see("name-error", "Display name is required"); + await app.type("name", "Bartholomew the Magnificent"); + await app.see("name-error", "Display name must be at most 20 characters, not 27"); + await app.eq("name-count", "27/20"); + await app.type("name", "Bea"); + await app.notSee("name-error", "Display name"); + } }, + { name: "a save shows at once, then lands", async run(app) { + await open(app, "settings"); + await saving(app, "Bea", "dark"); + await app.eq("saved", "Saved: Bea · dark · compact (saving…)"); + await app.landOnly("saveSettings", { name: "Bea", theme: "dark", density: "compact" }); + await app.eq("saved", "Saved: Bea · dark · compact"); + } }, + { name: "a failed load says so", async run(app) { + await open(app, "settings"); + await app.fail("loadSettings:", "network down"); + await app.see("load", "Could not load settings: network down"); + } }, + { name: "tabs: settings, then back to issues, starts the issues page again", async run(app) { + await selected(app); + await open(app, "settings"); + await app.see("load", "Loading settings…"); + await app.notSee("list", "Crash on save"); + await open(app, "issues"); + await app.see("list", "loading open issues"); + await app.see("detail", "select an issue"); + } }, +]; diff --git a/eval/tasks/labels/BASE.md b/eval/tasks/labels/BASE.md new file mode 100644 index 0000000..e6a26eb --- /dev/null +++ b/eval/tasks/labels/BASE.md @@ -0,0 +1,143 @@ +# Issue desk + +Build an issue desk: three pages behind a row of tabs. The issues page is an issue tracker with assigning, renaming, +closing and comments; the help page searches a help catalogue; the settings page edits the user's settings. + +## The API (`src/api.js`, given; do not change it) + +Issues: + +- `fetchIssues(filter: "open" | "closed"): Promise<{ id: number; title: string; assignee: string | null }[]>`. May + reject with an `Error`. +- `fetchIssue(id: number): Promise<{ id: number; title: string; body: string } | null>`: `null` when there is no such + issue. May reject with an `Error`. +- `assign(id: number, user: string): Promise<{ id: number; assignee: string }>`: assigns the issue on the server. May + reject with an `Error` (the server declined). +- `renameIssue(id: number, title: string): Promise<{ id: number; title: string }>`: renames the issue on the server. + May reject with an `Error` (the server declined). +- `closeIssue(id: number): Promise<{ id: number }>`: closes the issue on the server. May reject with an `Error` (the + server declined). +- `fetchComments(id: number): Promise<{ author: string; text: string }[]>`: the issue's comments, oldest first. May + reject with an `Error`. +- `addComment(id: number, author: string, text: string): Promise<{ author: string; text: string }>`: adds a comment + on the server. May reject with an `Error` (the server declined). + +Help: + +- `search(q: string): Promise<{ id: number; name: string }[]>`: the hits for `q`, in order. It may reject with an + `Error`. +- `fetchItem(id: number): Promise<{ id: number; name: string; blurb: string } | null>`: one item, or `null` when there + is no item with that id. It may reject with an `Error`. + +Settings: + +- `loadSettings(): Promise<{ name: string; theme: "light" | "dark"; density: "comfortable" | "compact" }>`. May + reject with an `Error`. +- `saveSettings(s: { name: string; theme: "light" | "dark"; density: "comfortable" | "compact" }): Promise<same shape>`: + answers the settings as saved. May reject with an `Error`. + +## What the app must do + +Elements the tests look for carry these `data-testid`s. Text in quotes must appear exactly as written. + +### Tabs + +1. Three buttons, `tab-issues`, `tab-help` and `tab-settings`, always on screen. The app opens on the issues page. +2. Only the open page is on screen. Leaving a page discards it: opening it again starts it as it starts when the app + opens (it loads again, nothing selected, inputs at their starting values). A request a discarded page made may + still answer; it must not show on, or change, any page. + +### The issues page + +1. **Current user.** A text input, `user`, holding the current user's name. It starts as `guest`. +2. **Filter.** Two buttons, `filter-open` and `filter-closed`. The filter starts as `open`. +3. **List**, `list`, of the issues for the current filter, from `fetchIssues(filter)`. + - While they load (when the page opens or the filter changes): `loading <filter> issues` (`loading closed issues`). + - Then one row per issue, `data-testid="issue"`, reading `#<id> <title> (<assignee>)`, or `(unassigned)` when the + assignee is `null`: `#3 Crash on save (unassigned)`. + - If it fails: `list error: <message>`. +4. **Detail**, `detail`. + - Nothing selected: `select an issue`. Clicking a row selects its issue. + - The selected issue comes from `fetchIssue(id)`. While it loads: `loading #<id>`. + - An issue: `#<id> <title>` and its body. + - `null`: `detail error: issue #<id> not found`. A failure: `detail error: <message>`. + - A button `retry` (`data-testid="retry"`) calls `fetchIssue(id)` again and shows its answer. +5. **Assign.** While an issue is selected, the detail has a button `assign` (`data-testid="assign"`) reading + `assign to <current user>`. Clicking it: + - shows the current user as the issue's assignee in its list row straight away; + - calls `assign(id, user)`; + - if the server declines, the row goes back to the assignee it had before, and the detail shows the server's + message (`declined`); + - if the server accepts, the row keeps the new assignee. + - An assignment error is cleared when a new assignment starts or another issue is selected. +6. **Rename.** While an issue is selected, the detail has a text input `title-input`, empty when the issue is + selected, and a button `rename` (`data-testid="rename"`). Clicking `rename` while `title-input` holds a title: + - shows the new title in the issue's list row and in the detail's heading (`#<id> <title>`) straight away; + - calls `renameIssue(id, title)`; + - if the server declines, the row and the heading go back to the title they had before, and the detail shows + `rename error: <message>` (`rename error: declined`); + - if the server accepts, the row and the heading keep the new title. + - A rename error is cleared when a new rename starts or another issue is selected. +7. **Close.** While an issue is selected, the detail has a button `close` (`data-testid="close"`). Clicking it: + - removes the issue's row from the list of open issues straight away (the detail keeps showing the issue); + - calls `closeIssue(id)`; + - if the server declines, the row comes back where it was, and the detail shows `close error: <message>` + (`close error: declined`); + - if the server accepts, the row stays out of the open issues. + - A close error is cleared when a new close starts or another issue is selected. +8. **Comments.** While an issue is selected, the detail has an element `comments` with that issue's comments, from + `fetchComments(id)`. + - While they load: `loading comments`. + - Then one row per comment, `data-testid="comment"`, reading `<author>: <text>` (`dana: seen it too`). + - If it fails: `comments error: <message>`. +9. **Posting.** The detail has a text input `comment-input` and a button `post` (`data-testid="post"`). Clicking + `post` while `comment-input` holds text: + - adds the comment as the last row straight away, reading `<current user>: <text> (sending…)`, and empties the + input; + - calls `addComment(id, user, text)`; + - if the server declines, the row goes away, and the detail shows `post error: <message>` (`post error: declined`); + - if the server accepts, the row stays and reads `<current user>: <text>`. + - A post error is cleared when a new post starts or another issue is selected. + +### The help page + +1. A text input, `query`. What the user types is the query. +2. A results area, `results`. + - Empty query: the results area shows nothing, and nothing is asked. + - A query of one character: the results area shows `too short: <q>` (for `a`: `too short: a`). Nothing is asked. + - Two characters or more: call `search(q)` and show `searching…` until the results arrive. Results already on + screen stay there until the new ones replace them. + - The results: a line `<n> results for <q>` (`2 results for ap`) and one list item per hit, `data-testid="hit"`, + whose text is the hit's name. + - If the search fails: `search failed: <message>`. +3. Clicking a hit selects it. The selected hit's text starts with `> ` (`> apple pie`); the others have no prefix. +4. A help pane, `item`. + - Nothing selected: `pick a result`. + - When a hit is selected, call `fetchItem(id)` and show `loading…` until it answers. + - The answer is an item: the pane shows its name and its blurb. + - The answer is `null`: `item <id> not found`. + - It fails: `failed: <message>` and a button `retry-item` (`data-testid="retry-item"`) that calls `fetchItem(id)` + again and shows its answer. + +### The settings page + +1. **Loading.** Call `loadSettings()` when the page opens. Until it answers, an element `load` shows + `Loading settings…`. If it fails, `load` shows `Could not load settings: <message>` and a button `retry-load` + calls `loadSettings()` again and shows the settings it answers. +2. **Saved line**, `saved`, once the settings have loaded: `Saved: <name> · <theme> · <density>` + (`Saved: Ann · light · compact`). +3. **The draft.** Once loaded, a form is filled from the loaded settings and then belongs to the user: + - an input `name` (the display name), a counter `name-count` reading `<length>/20`; + - `name-error`, shown only when the name is invalid: `Display name is required` when empty, + `Display name must be at most 20 characters, not <length>` when longer than 20; + - buttons `theme-light` and `theme-dark` that set the draft's theme; a button `density-toggle` that switches the + draft's density between `comfortable` and `compact`; + - `preview` reading `Preview: <draft theme> / <draft density>`. +4. **Save**, a button `save`. + - With an invalid name it does nothing (nothing is asked). + - Otherwise it calls `saveSettings(draft)`. Straight away the saved line shows the values being saved followed by + ` (saving…)` (`Saved: Bea · dark · compact (saving…)`), and any previous save error disappears. + - When the save succeeds, the saved line shows the saved settings, without `(saving…)`. + - When it fails, the saved line goes back to what was saved before, without `(saving…)`, and `save-error` shows + `Save failed: <message>`. + - Saving never changes the draft. diff --git a/eval/tasks/labels/api.d.rx b/eval/tasks/labels/api.d.rx new file mode 100644 index 0000000..f66befd --- /dev/null +++ b/eval/tasks/labels/api.d.rx @@ -0,0 +1,23 @@ +// src/api.js, as .rx sees it. fetchIssues, fetchIssue, fetchComments, fetchLabels, search, fetchItem and loadSettings +// are loaders (safe to ask from an equation); assign, renameIssue, closeIssue, addComment, addLabel, removeLabel and +// saveSettings write. +export type Issue = { id: number; title: string; assignee: string | null } +export type IssueDetail = { id: number; title: string; body: string } +export pure fn fetchIssues(filter: string): Promise<Issue[]> +export pure fn fetchIssue(id: number): Promise<IssueDetail | null> +export fn assign(id: number, user: string): Promise<{ id: number; assignee: string }> +export fn renameIssue(id: number, title: string): Promise<{ id: number; title: string }> +export fn closeIssue(id: number): Promise<{ id: number }> +export type Comment = { author: string; text: string } +export pure fn fetchComments(id: number): Promise<Comment[]> +export fn addComment(id: number, author: string, text: string): Promise<Comment> +export type Hit = { id: number; name: string } +export type Item = { id: number; name: string; blurb: string } +export pure fn search(q: string): Promise<Hit[]> +export pure fn fetchItem(id: number): Promise<Item | null> +export type Settings = { name: string; theme: string; density: string } +export pure fn loadSettings(): Promise<Settings> +export fn saveSettings(s: Settings): Promise<Settings> +export pure fn fetchLabels(id: number): Promise<string[]> +export fn addLabel(id: number, label: string): Promise<string[]> +export fn removeLabel(id: number, label: string): Promise<string[]> diff --git a/eval/tasks/labels/api.d.ts b/eval/tasks/labels/api.d.ts new file mode 100644 index 0000000..0acb5a4 --- /dev/null +++ b/eval/tasks/labels/api.d.ts @@ -0,0 +1,22 @@ +export type Issue = { id: number; title: string; assignee: string | null }; +export type IssueDetail = { id: number; title: string; body: string }; +export function fetchIssues(filter: "open" | "closed"): Promise<Issue[]>; +export function fetchIssue(id: number): Promise<IssueDetail | null>; +export function assign(id: number, user: string): Promise<{ id: number; assignee: string }>; +export function renameIssue(id: number, title: string): Promise<{ id: number; title: string }>; +export function closeIssue(id: number): Promise<{ id: number }>; +export type Comment = { author: string; text: string }; +export function fetchComments(id: number): Promise<Comment[]>; +export function addComment(id: number, author: string, text: string): Promise<Comment>; +export type Hit = { id: number; name: string }; +export type Item = { id: number; name: string; blurb: string }; +export function search(q: string): Promise<Hit[]>; +export function fetchItem(id: number): Promise<Item | null>; +export type Theme = "light" | "dark"; +export type Density = "comfortable" | "compact"; +export type Settings = { name: string; theme: Theme; density: Density }; +export function loadSettings(): Promise<Settings>; +export function saveSettings(s: Settings): Promise<Settings>; +export function fetchLabels(id: number): Promise<string[]>; +export function addLabel(id: number, label: string): Promise<string[]>; +export function removeLabel(id: number, label: string): Promise<string[]>; diff --git a/eval/tasks/labels/api.js b/eval/tasks/labels/api.js new file mode 100644 index 0000000..ac4c81c --- /dev/null +++ b/eval/tasks/labels/api.js @@ -0,0 +1,16 @@ +import { flight } from "./flights.js"; + +export const fetchIssues = filter => flight("fetchIssues", [filter]); +export const fetchIssue = id => flight("fetchIssue", [id]); +export const assign = (id, user) => flight("assign", [id, user]); +export const renameIssue = (id, title) => flight("renameIssue", [id, title]); +export const closeIssue = id => flight("closeIssue", [id]); +export const fetchComments = id => flight("fetchComments", [id]); +export const addComment = (id, author, text) => flight("addComment", [id, author, text]); +export const search = q => flight("search", [q]); +export const fetchItem = id => flight("fetchItem", [id]); +export const loadSettings = () => flight("loadSettings", []); +export const saveSettings = s => flight("saveSettings", [s]); +export const fetchLabels = id => flight("fetchLabels", [id]); +export const addLabel = (id, label) => flight("addLabel", [id, label]); +export const removeLabel = (id, label) => flight("removeLabel", [id, label]); diff --git a/eval/tasks/labels/brief-plain.md b/eval/tasks/labels/brief-plain.md new file mode 100644 index 0000000..d1bb8ce --- /dev/null +++ b/eval/tasks/labels/brief-plain.md @@ -0,0 +1,39 @@ +# Change request: labels + +The app in `src/` is an issue desk that already works. `BASE.md` is the brief it was built to. Keep everything it +does, and add labels to the selected issue. + +## The API (`src/api.js`, given; do not change it) + +Everything in `BASE.md`, and three new functions: + +- `fetchLabels(id: number): Promise<string[]>`: the issue's labels, in order. May reject with an `Error`. +- `addLabel(id: number, label: string): Promise<string[]>`: adds a label at the end on the server; answers the + issue's labels after the change. May reject with an `Error` (the server declined). +- `removeLabel(id: number, label: string): Promise<string[]>`: removes a label on the server; answers the issue's + labels after the change. May reject with an `Error` (the server declined). + +## What to add + +Elements the tests look for carry these `data-testid`s. Text in quotes must appear exactly as written. + +1. **Labels.** While an issue is selected, the detail has an element `labels` with that issue's labels, from + `fetchLabels(id)`. + - While they load: `loading labels`. + - Then, per label, an element `data-testid="label"` reading the label (`bug`), and next to it a button + `data-testid="remove-label"` reading `remove <label>` (`remove bug`). + - If it fails: `labels error: <message>`. +2. **Adding.** The detail has a text input `label-input` and a button `add-label`. Clicking `add-label` while + `label-input` holds a label the issue does not have yet: + - adds the label as the last one straight away and empties the input; + - calls `addLabel(id, label)`; + - if the server declines, the label goes away, and the detail shows `label error: <message>` + (`label error: declined`); + - if the server accepts, the label stays. + - A label the issue already shows is not added again, and nothing is asked. +3. **Removing.** Clicking a label's `remove-label` button: + - removes the label straight away; + - calls `removeLabel(id, label)`; + - if the server declines, the label comes back where it was, and the detail shows `label error: <message>`; + - if the server accepts, the label stays out. +4. A label error is cleared when a new label change (an add or a remove) starts or another issue is selected. diff --git a/eval/tasks/labels/fixtures.mjs b/eval/tasks/labels/fixtures.mjs new file mode 100644 index 0000000..a74445a --- /dev/null +++ b/eval/tasks/labels/fixtures.mjs @@ -0,0 +1,53 @@ +// Issues +export const OPEN = [{ id: 1, title: "Login fails", assignee: null }, { id: 3, title: "Crash on save", assignee: null }, { id: 4, title: "Typo in footer", assignee: "dana" }]; +export const CLOSED = [{ id: 2, title: "Old bug", assignee: "eve" }]; +export const OPEN_ROWS = ["#1 Login fails (unassigned)", "#3 Crash on save (unassigned)", "#4 Typo in footer (dana)"]; +export const D1 = { id: 1, title: "Login fails", body: "on Safari only" }; +export const D3 = { id: 3, title: "Crash on save", body: "stack trace attached" }; +export const C3 = [{ author: "dana", text: "seen it too" }]; +export const C1 = []; +/** the open issues loaded */ +export async function loaded(app) { await app.land("fetchIssues:open", OPEN); } +/** the open issues loaded and issue `id` selected with its detail */ +export async function selected(app, id = 3, detail = D3) { await loaded(app); await app.clickText("issue", `#${id} `); await app.land(`fetchIssue:${id}`, detail); } +/** issue 3 selected with its detail and its comments */ +export async function withComments(app) { await selected(app); await app.land("fetchComments:3", C3); } +/** type `title` into the detail's title input and click rename */ +export async function rename(app, title) { await app.type("title-input", title); await app.click("rename"); } +/** type `text` into the comment input and click post */ +export async function post(app, text) { await app.type("comment-input", text); await app.click("post"); } +export const openWithout = id => OPEN.filter(i => i.id !== id); +export const rowsWithout = id => OPEN_ROWS.filter(r => !r.startsWith(`#${id} `)); + +// Help +export const AP = [{ id: 1, name: "apple" }, { id: 2, name: "apricot" }]; +export const APP = [{ id: 1, name: "apple" }, { id: 3, name: "applesauce" }, { id: 4, name: "apple pie" }]; +export const ITEM3 = { id: 3, name: "applesauce", blurb: "stewed apples" }; +export const ITEM4 = { id: 4, name: "apple pie", blurb: "baked with cinnamon" }; +/** type a query and land its answer */ +export async function searched(app, q, hits) { await app.type("query", q); await app.land(`search:${q}`, hits); } + +// Settings +export const ANN = { name: "Ann", theme: "light", density: "compact" }; +export async function settingsLoaded(app) { await app.land("loadSettings:", ANN); } +/** loaded, the name typed, the theme picked, then saved: returns the request's key */ +export async function saving(app, name, theme) { + await settingsLoaded(app); + await app.type("name", name); + if (theme) await app.click(`theme-${theme}`); + await app.click("save"); + return app.only("saveSettings"); +} + +/** open the help or settings tab first */ +export async function open(app, page) { await app.click(`tab-${page}`); } + +// Labels +export const L3 = ["bug", "ui"]; +export const L1 = []; +/** issue 3 selected with its detail, comments and labels */ +export async function withLabels(app) { await selected(app); await app.landIfAsked("fetchComments:3", C3); await app.land("fetchLabels:3", L3); } +/** type `label` into the label input and click add-label */ +export async function addLabel(app, label) { await app.type("label-input", label); await app.click("add-label"); } +/** click the remove button of `label` */ +export async function removeLabel(app, label) { await app.clickText("remove-label", `remove ${label}`); } diff --git a/eval/tasks/labels/hidden-overlap.mjs b/eval/tasks/labels/hidden-overlap.mjs new file mode 100644 index 0000000..4a19bfb --- /dev/null +++ b/eval/tasks/labels/hidden-overlap.mjs @@ -0,0 +1,65 @@ +// Round 5 hidden tests: the change's writes overlapping each other and the desk's, and the desk's overlap tests as +// regressions. Never shown to the agent. +import base from "../desk/hidden-overlap.mjs"; +import { C1, C3, D1, D3, L1, L3, withLabels, addLabel, removeLabel } from "./fixtures.mjs"; + +export default [ + ...base.map(t => ({ ...t, name: `base: ${t.name}` })), + { name: "an add and a remove at once: a declined add does not undo the remove", async run(app) { + await withLabels(app); + await addLabel(app, "crash"); + await removeLabel(app, "bug"); + await app.fail("addLabel:3,crash", "declined"); + // the declined add may stay until the remove settles (two guesses on one list are one change, H-11); the + // remove still out must not come undone + await app.notSee("labels", "bug"); + await app.land("removeLabel:3,bug", ["ui"]); + await app.landIfAsked("fetchLabels:3", ["ui"]); + await app.rows("label", ["ui"]); + } }, + { name: "two adds at once: a declined one does not take the other with it", async run(app) { + await withLabels(app); + await addLabel(app, "crash"); + await addLabel(app, "p1"); + await app.fail("addLabel:3,crash", "declined"); + await app.see("labels", "p1"); + await app.land("addLabel:3,p1", [...L3, "p1"]); + await app.landIfAsked("fetchLabels:3", [...L3, "p1"]); + await app.rows("label", ["bug", "ui", "p1"]); + } }, + { name: "a label added on one issue does not show on another", async run(app) { + await withLabels(app); + await addLabel(app, "crash"); + await app.clickText("issue", "#1 "); await app.landIfAsked("fetchIssue:1", D1); await app.landIfAsked("fetchComments:1", C1); + await app.landIfAsked("fetchLabels:1", L1); + // the selection may be held until the add settles (see comments' "a post on one issue…"); what must not show is + // the label under #1 + if ((await app.text("labels") ?? "").includes("crash")) await app.see("detail", "Crash on save"); + await app.land("addLabel:3,crash", [...L3, "crash"]); + await app.landIfAsked("fetchIssue:1", D1); await app.landIfAsked("fetchComments:1", C1); await app.landIfAsked("fetchLabels:1", L1); + await app.landIfAsked("fetchLabels:3", [...L3, "crash"]); + await app.see("detail", "Login fails"); + await app.notSee("labels", "crash"); + } }, + { name: "labels that load while an add is out keep the add", async run(app) { + await withLabels(app); + await addLabel(app, "crash"); + await app.clickText("issue", "#1 "); await app.land("fetchIssue:1", D1); await app.landIfAsked("fetchComments:1", C1); + await app.land("fetchLabels:1", L1); + await app.clickText("issue", "#3 "); await app.landIfAsked("fetchIssue:3", D3); await app.landIfAsked("fetchComments:3", C3); + await app.landIfAsked("fetchLabels:3", L3); + await app.rows("label", ["bug", "ui", "crash"]); + await app.land("addLabel:3,crash", [...L3, "crash"]); + await app.landIfAsked("fetchLabels:3", [...L3, "crash"]); + await app.rows("label", ["bug", "ui", "crash"]); + } }, + { name: "a label change declined while a rename is out leaves the rename", async run(app) { + await withLabels(app); + await app.type("title-input", "Crash at boot"); await app.click("rename"); + await removeLabel(app, "ui"); + await app.fail("removeLabel:3,ui", "declined"); + await app.see("list", "#3 Crash at boot (unassigned)"); + await app.see("detail", "#3 Crash at boot", "label error: declined"); + await app.rows("label", ["bug", "ui"]); + } }, +]; diff --git a/eval/tasks/labels/hidden.mjs b/eval/tasks/labels/hidden.mjs new file mode 100644 index 0000000..88574e7 --- /dev/null +++ b/eval/tasks/labels/hidden.mjs @@ -0,0 +1,76 @@ +// Round 5 hidden tests: the change's own behaviour, and the desk's hidden tests as regressions. Never shown to the +// agent. +import base from "../desk/hidden.mjs"; +import { C1, C3, D1, D3, L1, L3, loaded, selected, withLabels, addLabel, removeLabel } from "./fixtures.mjs"; + +/** switch to issue 1 and land what it asks */ +async function toIssue1(app) { + await app.clickText("issue", "#1 "); + await app.landIfAsked("fetchIssue:1", D1); await app.landIfAsked("fetchComments:1", C1); await app.landIfAsked("fetchLabels:1", L1); +} + +export default [ + ...base.map(t => ({ ...t, name: `base: ${t.name}` })), + { name: "a declined add removes the label and shows the message", async run(app) { + await withLabels(app); + await addLabel(app, "crash"); + await app.fail("addLabel:3,crash", "declined"); + await app.rows("label", ["bug", "ui"]); + await app.see("detail", "label error: declined"); + } }, + { name: "a declined remove puts the label back where it was and shows the message", async run(app) { + await withLabels(app); + await removeLabel(app, "bug"); + await app.fail("removeLabel:3,bug", "declined"); + await app.rows("label", ["bug", "ui"]); + await app.see("detail", "label error: declined"); + } }, + { name: "adding a label the issue already has asks nothing", async run(app) { + await withLabels(app); + await addLabel(app, "ui"); + await app.noneInAir("addLabel"); + await app.rows("label", ["bug", "ui"]); + } }, + { name: "a new label change clears the previous error at once", async run(app) { + await withLabels(app); + await addLabel(app, "crash"); + await app.fail("addLabel:3,crash", "declined"); + await removeLabel(app, "ui"); + await app.notSee("detail", "label error"); + await app.rows("label", ["bug"]); + } }, + { name: "selecting another issue clears the label error", async run(app) { + await withLabels(app); + await addLabel(app, "crash"); + await app.fail("addLabel:3,crash", "declined"); + await toIssue1(app); + await app.notSee("detail", "label error"); + } }, + { name: "labels landing out of order show the selected issue's", async run(app) { + await loaded(app); + await app.clickText("issue", "#3 "); await app.clickText("issue", "#1 "); + await app.land("fetchIssue:1", D1); + await app.landIfAsked("fetchComments:1", C1); + await app.land("fetchLabels:1", ["docs"]); + await app.landIfAsked("fetchIssue:3", D3); + await app.landIfAsked("fetchComments:3", C3); + await app.landIfAsked("fetchLabels:3", L3); + await app.rows("label", ["docs"]); + } }, + { name: "failing labels show their error and leave the issue", async run(app) { + await selected(app); + await app.fail("fetchLabels:3", "timeout"); + await app.see("labels", "labels error: timeout"); + await app.see("detail", "#3 Crash on save", "stack trace attached"); + } }, + { name: "an accepted add stays after switching issues and back", async run(app) { + await withLabels(app); + await addLabel(app, "crash"); + await app.land("addLabel:3,crash", [...L3, "crash"]); + await app.landIfAsked("fetchLabels:3", [...L3, "crash"]); + await toIssue1(app); + await app.clickText("issue", "#3 "); await app.landIfAsked("fetchIssue:3", D3); await app.landIfAsked("fetchComments:3", C3); + await app.landIfAsked("fetchLabels:3", [...L3, "crash"]); + await app.rows("label", ["bug", "ui", "crash"]); + } }, +]; diff --git a/eval/tasks/labels/visible.mjs b/eval/tasks/labels/visible.mjs new file mode 100644 index 0000000..f267e22 --- /dev/null +++ b/eval/tasks/labels/visible.mjs @@ -0,0 +1,176 @@ +// Visible tests: the agent sees and runs these. The desk's, then the change's. +import { AP, APP, C3, CLOSED, D3, ITEM3, L3, OPEN, OPEN_ROWS, addLabel, loaded, open, post, removeLabel, rename, rowsWithout, saving, searched, selected, settingsLoaded, withComments, withLabels } from "./fixtures.mjs"; + +export default [ + { name: "on load: the open issues load", async run(app) { + await app.see("list", "loading open issues"); + await app.see("detail", "select an issue"); + await loaded(app); + await app.rows("issue", OPEN_ROWS); + } }, + { name: "switching the filter loads the closed issues", async run(app) { + await loaded(app); + await app.click("filter-closed"); + await app.see("list", "loading closed issues"); + await app.land("fetchIssues:closed", CLOSED); + await app.rows("issue", ["#2 Old bug (eve)"]); + } }, + { name: "selecting an issue loads its detail", async run(app) { + await loaded(app); + await app.clickText("issue", "#3 "); + await app.see("detail", "loading #3"); + await app.land("fetchIssue:3", D3); + await app.see("detail", "#3 Crash on save", "stack trace attached"); + } }, + { name: "assigning shows the new assignee at once", async run(app) { + await selected(app); + await app.type("user", "ann"); + await app.see("assign", "assign to ann"); + await app.click("assign"); + await app.inAir("assign:3,ann"); + await app.see("list", "#3 Crash on save (ann)"); + await app.land("assign:3,ann", { id: 3, assignee: "ann" }); + await app.see("list", "#3 Crash on save (ann)"); + } }, + { name: "renaming shows the new title at once in the row and the heading", async run(app) { + await selected(app); + await rename(app, "Crash at boot"); + await app.inAir("renameIssue:3,Crash at boot"); + await app.see("list", "#3 Crash at boot (unassigned)"); + await app.see("detail", "#3 Crash at boot"); + } }, + { name: "an accepted rename keeps the new title", async run(app) { + await selected(app); + await rename(app, "Crash at boot"); + await app.land("renameIssue:3,Crash at boot", { id: 3, title: "Crash at boot" }); + await app.see("list", "#3 Crash at boot (unassigned)"); + await app.see("detail", "#3 Crash at boot"); + } }, + { name: "closing removes the row at once and keeps the detail", async run(app) { + await selected(app); + await app.click("close"); + await app.inAir("closeIssue:3"); + await app.rows("issue", rowsWithout(3)); + await app.see("detail", "#3 Crash on save"); + } }, + { name: "an accepted close keeps the row out", async run(app) { + await selected(app); + await app.click("close"); + await app.land("closeIssue:3", { id: 3 }); + await app.rows("issue", rowsWithout(3)); + } }, + { name: "selecting an issue loads its comments", async run(app) { + await selected(app); + await app.see("comments", "loading comments"); + await app.land("fetchComments:3", [{ author: "dana", text: "seen it too" }]); + await app.rows("comment", ["dana: seen it too"]); + } }, + { name: "posting shows the comment at once and keeps it when accepted", async run(app) { + await withComments(app); + await app.type("user", "ann"); + await post(app, "on it"); + await app.inAir("addComment:3,ann,on it"); + await app.rows("comment", ["dana: seen it too", "ann: on it (sending…)"]); + await app.hasValue("comment-input", ""); + await app.land("addComment:3,ann,on it", { author: "ann", text: "on it" }); + await app.landIfAsked("fetchComments:3", [{ author: "dana", text: "seen it too" }, { author: "ann", text: "on it" }]); + await app.rows("comment", ["dana: seen it too", "ann: on it"]); + } }, + { name: "help on open: nothing selected, nothing asked", async run(app) { + await open(app, "help"); + await app.see("item", "pick a result"); + if ((await app.asked()).some(k => /^(search|fetchItem):/.test(k))) throw new Error(`asked ${await app.asked()} on open`); + } }, + { name: "one character is too short and asks nothing", async run(app) { + await open(app, "help"); + await app.type("query", "a"); + await app.see("results", "too short: a"); + await app.notAsked("search:a"); + } }, + { name: "a search shows searching…, then its results", async run(app) { + await open(app, "help"); + await app.type("query", "ap"); + await app.inAir("search:ap"); + await app.see("results", "searching…"); + await app.land("search:ap", AP); + await app.see("results", "2 results for ap"); + await app.notSee("results", "searching…"); + await app.rows("hit", ["apple", "apricot"]); + } }, + { name: "selecting a hit loads its detail", async run(app) { + await open(app, "help"); + await searched(app, "app", APP); + await app.clickText("hit", "applesauce"); + await app.rows("hit", ["apple", "> applesauce", "apple pie"]); + await app.see("item", "loading…"); + await app.land("fetchItem:3", ITEM3); + await app.see("item", "applesauce", "stewed apples"); + } }, + { name: "loads, then shows the saved line and the form", async run(app) { + await open(app, "settings"); + await app.see("load", "Loading settings…"); + await settingsLoaded(app); + await app.eq("saved", "Saved: Ann · light · compact"); + await app.hasValue("name", "Ann"); + await app.eq("name-count", "3/20"); + await app.eq("preview", "Preview: light / compact"); + } }, + { name: "validates the name", async run(app) { + await open(app, "settings"); + await settingsLoaded(app); + await app.type("name", ""); + await app.see("name-error", "Display name is required"); + await app.type("name", "Bartholomew the Magnificent"); + await app.see("name-error", "Display name must be at most 20 characters, not 27"); + await app.eq("name-count", "27/20"); + await app.type("name", "Bea"); + await app.notSee("name-error", "Display name"); + } }, + { name: "a save shows at once, then lands", async run(app) { + await open(app, "settings"); + await saving(app, "Bea", "dark"); + await app.eq("saved", "Saved: Bea · dark · compact (saving…)"); + await app.landOnly("saveSettings", { name: "Bea", theme: "dark", density: "compact" }); + await app.eq("saved", "Saved: Bea · dark · compact"); + } }, + { name: "a failed load says so", async run(app) { + await open(app, "settings"); + await app.fail("loadSettings:", "network down"); + await app.see("load", "Could not load settings: network down"); + } }, + { name: "tabs: settings, then back to issues, starts the issues page again", async run(app) { + await selected(app); + await open(app, "settings"); + await app.see("load", "Loading settings…"); + await app.notSee("list", "Crash on save"); + await open(app, "issues"); + await app.see("list", "loading open issues"); + await app.see("detail", "select an issue"); + } }, + { name: "selecting an issue loads its labels", async run(app) { + await selected(app); + await app.see("labels", "loading labels"); + await app.landIfAsked("fetchComments:3", C3); + await app.land("fetchLabels:3", L3); + await app.rows("label", ["bug", "ui"]); + } }, + { name: "adding a label shows it at once and keeps it when accepted", async run(app) { + await withLabels(app); + await addLabel(app, "crash"); + await app.inAir("addLabel:3,crash"); + await app.rows("label", ["bug", "ui", "crash"]); + await app.hasValue("label-input", ""); + await app.land("addLabel:3,crash", [...L3, "crash"]); + await app.landIfAsked("fetchLabels:3", [...L3, "crash"]); + await app.rows("label", ["bug", "ui", "crash"]); + } }, + { name: "removing a label takes it out at once", async run(app) { + await withLabels(app); + await removeLabel(app, "bug"); + await app.inAir("removeLabel:3,bug"); + await app.rows("label", ["ui"]); + await app.land("removeLabel:3,bug", ["ui"]); + await app.landIfAsked("fetchLabels:3", ["ui"]); + await app.rows("label", ["ui"]); + } }, +]; diff --git a/eval/tasks/related/BASE.md b/eval/tasks/related/BASE.md new file mode 100644 index 0000000..e6a26eb --- /dev/null +++ b/eval/tasks/related/BASE.md @@ -0,0 +1,143 @@ +# Issue desk + +Build an issue desk: three pages behind a row of tabs. The issues page is an issue tracker with assigning, renaming, +closing and comments; the help page searches a help catalogue; the settings page edits the user's settings. + +## The API (`src/api.js`, given; do not change it) + +Issues: + +- `fetchIssues(filter: "open" | "closed"): Promise<{ id: number; title: string; assignee: string | null }[]>`. May + reject with an `Error`. +- `fetchIssue(id: number): Promise<{ id: number; title: string; body: string } | null>`: `null` when there is no such + issue. May reject with an `Error`. +- `assign(id: number, user: string): Promise<{ id: number; assignee: string }>`: assigns the issue on the server. May + reject with an `Error` (the server declined). +- `renameIssue(id: number, title: string): Promise<{ id: number; title: string }>`: renames the issue on the server. + May reject with an `Error` (the server declined). +- `closeIssue(id: number): Promise<{ id: number }>`: closes the issue on the server. May reject with an `Error` (the + server declined). +- `fetchComments(id: number): Promise<{ author: string; text: string }[]>`: the issue's comments, oldest first. May + reject with an `Error`. +- `addComment(id: number, author: string, text: string): Promise<{ author: string; text: string }>`: adds a comment + on the server. May reject with an `Error` (the server declined). + +Help: + +- `search(q: string): Promise<{ id: number; name: string }[]>`: the hits for `q`, in order. It may reject with an + `Error`. +- `fetchItem(id: number): Promise<{ id: number; name: string; blurb: string } | null>`: one item, or `null` when there + is no item with that id. It may reject with an `Error`. + +Settings: + +- `loadSettings(): Promise<{ name: string; theme: "light" | "dark"; density: "comfortable" | "compact" }>`. May + reject with an `Error`. +- `saveSettings(s: { name: string; theme: "light" | "dark"; density: "comfortable" | "compact" }): Promise<same shape>`: + answers the settings as saved. May reject with an `Error`. + +## What the app must do + +Elements the tests look for carry these `data-testid`s. Text in quotes must appear exactly as written. + +### Tabs + +1. Three buttons, `tab-issues`, `tab-help` and `tab-settings`, always on screen. The app opens on the issues page. +2. Only the open page is on screen. Leaving a page discards it: opening it again starts it as it starts when the app + opens (it loads again, nothing selected, inputs at their starting values). A request a discarded page made may + still answer; it must not show on, or change, any page. + +### The issues page + +1. **Current user.** A text input, `user`, holding the current user's name. It starts as `guest`. +2. **Filter.** Two buttons, `filter-open` and `filter-closed`. The filter starts as `open`. +3. **List**, `list`, of the issues for the current filter, from `fetchIssues(filter)`. + - While they load (when the page opens or the filter changes): `loading <filter> issues` (`loading closed issues`). + - Then one row per issue, `data-testid="issue"`, reading `#<id> <title> (<assignee>)`, or `(unassigned)` when the + assignee is `null`: `#3 Crash on save (unassigned)`. + - If it fails: `list error: <message>`. +4. **Detail**, `detail`. + - Nothing selected: `select an issue`. Clicking a row selects its issue. + - The selected issue comes from `fetchIssue(id)`. While it loads: `loading #<id>`. + - An issue: `#<id> <title>` and its body. + - `null`: `detail error: issue #<id> not found`. A failure: `detail error: <message>`. + - A button `retry` (`data-testid="retry"`) calls `fetchIssue(id)` again and shows its answer. +5. **Assign.** While an issue is selected, the detail has a button `assign` (`data-testid="assign"`) reading + `assign to <current user>`. Clicking it: + - shows the current user as the issue's assignee in its list row straight away; + - calls `assign(id, user)`; + - if the server declines, the row goes back to the assignee it had before, and the detail shows the server's + message (`declined`); + - if the server accepts, the row keeps the new assignee. + - An assignment error is cleared when a new assignment starts or another issue is selected. +6. **Rename.** While an issue is selected, the detail has a text input `title-input`, empty when the issue is + selected, and a button `rename` (`data-testid="rename"`). Clicking `rename` while `title-input` holds a title: + - shows the new title in the issue's list row and in the detail's heading (`#<id> <title>`) straight away; + - calls `renameIssue(id, title)`; + - if the server declines, the row and the heading go back to the title they had before, and the detail shows + `rename error: <message>` (`rename error: declined`); + - if the server accepts, the row and the heading keep the new title. + - A rename error is cleared when a new rename starts or another issue is selected. +7. **Close.** While an issue is selected, the detail has a button `close` (`data-testid="close"`). Clicking it: + - removes the issue's row from the list of open issues straight away (the detail keeps showing the issue); + - calls `closeIssue(id)`; + - if the server declines, the row comes back where it was, and the detail shows `close error: <message>` + (`close error: declined`); + - if the server accepts, the row stays out of the open issues. + - A close error is cleared when a new close starts or another issue is selected. +8. **Comments.** While an issue is selected, the detail has an element `comments` with that issue's comments, from + `fetchComments(id)`. + - While they load: `loading comments`. + - Then one row per comment, `data-testid="comment"`, reading `<author>: <text>` (`dana: seen it too`). + - If it fails: `comments error: <message>`. +9. **Posting.** The detail has a text input `comment-input` and a button `post` (`data-testid="post"`). Clicking + `post` while `comment-input` holds text: + - adds the comment as the last row straight away, reading `<current user>: <text> (sending…)`, and empties the + input; + - calls `addComment(id, user, text)`; + - if the server declines, the row goes away, and the detail shows `post error: <message>` (`post error: declined`); + - if the server accepts, the row stays and reads `<current user>: <text>`. + - A post error is cleared when a new post starts or another issue is selected. + +### The help page + +1. A text input, `query`. What the user types is the query. +2. A results area, `results`. + - Empty query: the results area shows nothing, and nothing is asked. + - A query of one character: the results area shows `too short: <q>` (for `a`: `too short: a`). Nothing is asked. + - Two characters or more: call `search(q)` and show `searching…` until the results arrive. Results already on + screen stay there until the new ones replace them. + - The results: a line `<n> results for <q>` (`2 results for ap`) and one list item per hit, `data-testid="hit"`, + whose text is the hit's name. + - If the search fails: `search failed: <message>`. +3. Clicking a hit selects it. The selected hit's text starts with `> ` (`> apple pie`); the others have no prefix. +4. A help pane, `item`. + - Nothing selected: `pick a result`. + - When a hit is selected, call `fetchItem(id)` and show `loading…` until it answers. + - The answer is an item: the pane shows its name and its blurb. + - The answer is `null`: `item <id> not found`. + - It fails: `failed: <message>` and a button `retry-item` (`data-testid="retry-item"`) that calls `fetchItem(id)` + again and shows its answer. + +### The settings page + +1. **Loading.** Call `loadSettings()` when the page opens. Until it answers, an element `load` shows + `Loading settings…`. If it fails, `load` shows `Could not load settings: <message>` and a button `retry-load` + calls `loadSettings()` again and shows the settings it answers. +2. **Saved line**, `saved`, once the settings have loaded: `Saved: <name> · <theme> · <density>` + (`Saved: Ann · light · compact`). +3. **The draft.** Once loaded, a form is filled from the loaded settings and then belongs to the user: + - an input `name` (the display name), a counter `name-count` reading `<length>/20`; + - `name-error`, shown only when the name is invalid: `Display name is required` when empty, + `Display name must be at most 20 characters, not <length>` when longer than 20; + - buttons `theme-light` and `theme-dark` that set the draft's theme; a button `density-toggle` that switches the + draft's density between `comfortable` and `compact`; + - `preview` reading `Preview: <draft theme> / <draft density>`. +4. **Save**, a button `save`. + - With an invalid name it does nothing (nothing is asked). + - Otherwise it calls `saveSettings(draft)`. Straight away the saved line shows the values being saved followed by + ` (saving…)` (`Saved: Bea · dark · compact (saving…)`), and any previous save error disappears. + - When the save succeeds, the saved line shows the saved settings, without `(saving…)`. + - When it fails, the saved line goes back to what was saved before, without `(saving…)`, and `save-error` shows + `Save failed: <message>`. + - Saving never changes the draft. diff --git a/eval/tasks/related/api.d.rx b/eval/tasks/related/api.d.rx new file mode 100644 index 0000000..25e52a8 --- /dev/null +++ b/eval/tasks/related/api.d.rx @@ -0,0 +1,19 @@ +// src/api.js, as .rx sees it. fetchIssues, fetchIssue, fetchComments, search, fetchItem and loadSettings are loaders +// (safe to ask from an equation); assign, renameIssue, closeIssue, addComment and saveSettings write. +export type Issue = { id: number; title: string; assignee: string | null } +export type IssueDetail = { id: number; title: string; body: string } +export pure fn fetchIssues(filter: string): Promise<Issue[]> +export pure fn fetchIssue(id: number): Promise<IssueDetail | null> +export fn assign(id: number, user: string): Promise<{ id: number; assignee: string }> +export fn renameIssue(id: number, title: string): Promise<{ id: number; title: string }> +export fn closeIssue(id: number): Promise<{ id: number }> +export type Comment = { author: string; text: string } +export pure fn fetchComments(id: number): Promise<Comment[]> +export fn addComment(id: number, author: string, text: string): Promise<Comment> +export type Hit = { id: number; name: string } +export type Item = { id: number; name: string; blurb: string } +export pure fn search(q: string): Promise<Hit[]> +export pure fn fetchItem(id: number): Promise<Item | null> +export type Settings = { name: string; theme: string; density: string } +export pure fn loadSettings(): Promise<Settings> +export fn saveSettings(s: Settings): Promise<Settings> diff --git a/eval/tasks/related/api.d.ts b/eval/tasks/related/api.d.ts new file mode 100644 index 0000000..2548c4d --- /dev/null +++ b/eval/tasks/related/api.d.ts @@ -0,0 +1,19 @@ +export type Issue = { id: number; title: string; assignee: string | null }; +export type IssueDetail = { id: number; title: string; body: string }; +export function fetchIssues(filter: "open" | "closed"): Promise<Issue[]>; +export function fetchIssue(id: number): Promise<IssueDetail | null>; +export function assign(id: number, user: string): Promise<{ id: number; assignee: string }>; +export function renameIssue(id: number, title: string): Promise<{ id: number; title: string }>; +export function closeIssue(id: number): Promise<{ id: number }>; +export type Comment = { author: string; text: string }; +export function fetchComments(id: number): Promise<Comment[]>; +export function addComment(id: number, author: string, text: string): Promise<Comment>; +export type Hit = { id: number; name: string }; +export type Item = { id: number; name: string; blurb: string }; +export function search(q: string): Promise<Hit[]>; +export function fetchItem(id: number): Promise<Item | null>; +export type Theme = "light" | "dark"; +export type Density = "comfortable" | "compact"; +export type Settings = { name: string; theme: Theme; density: Density }; +export function loadSettings(): Promise<Settings>; +export function saveSettings(s: Settings): Promise<Settings>; diff --git a/eval/tasks/related/api.js b/eval/tasks/related/api.js new file mode 100644 index 0000000..33a7a76 --- /dev/null +++ b/eval/tasks/related/api.js @@ -0,0 +1,13 @@ +import { flight } from "./flights.js"; + +export const fetchIssues = filter => flight("fetchIssues", [filter]); +export const fetchIssue = id => flight("fetchIssue", [id]); +export const assign = (id, user) => flight("assign", [id, user]); +export const renameIssue = (id, title) => flight("renameIssue", [id, title]); +export const closeIssue = id => flight("closeIssue", [id]); +export const fetchComments = id => flight("fetchComments", [id]); +export const addComment = (id, author, text) => flight("addComment", [id, author, text]); +export const search = q => flight("search", [q]); +export const fetchItem = id => flight("fetchItem", [id]); +export const loadSettings = () => flight("loadSettings", []); +export const saveSettings = s => flight("saveSettings", [s]); diff --git a/eval/tasks/related/brief-plain.md b/eval/tasks/related/brief-plain.md new file mode 100644 index 0000000..cdc8f0b --- /dev/null +++ b/eval/tasks/related/brief-plain.md @@ -0,0 +1,23 @@ +# Change request: related help + +The app in `src/` is an issue desk that already works. `BASE.md` is the brief it was built to. Keep everything it +does, and show help related to the selected issue, which opens on the help page. + +## The API (`src/api.js`, given; do not change it) + +Everything in `BASE.md`. Nothing new: related help comes from `search`. + +## What to add + +Elements the tests look for carry these `data-testid`s. Text in quotes must appear exactly as written. + +1. **Related help.** While an issue is selected and its detail has loaded, the detail has an element `related`. + - It calls `search(<the issue's title>)` (for `#3 Crash on save`: `search("Crash on save")`), and shows + `finding related help` until the hits arrive. + - Then one button per hit, at most the first three, `data-testid="related-hit"`, reading the hit's name; or + `no related help` when there are none. + - If the search fails: `related help failed: <message>`. +2. **Opening a related hit.** Clicking a `related-hit` opens the help page as if the user had typed the issue's title + into `query` and clicked that hit: `query` holds the title, the results area shows the results for it, the hit is + selected (`> <name>`), and the help pane shows the item from `fetchItem(id)` (`loading…` until it answers). +3. Opening the help page with its tab still starts it as before: empty query, nothing selected. diff --git a/eval/tasks/related/fixtures.mjs b/eval/tasks/related/fixtures.mjs new file mode 100644 index 0000000..264acad --- /dev/null +++ b/eval/tasks/related/fixtures.mjs @@ -0,0 +1,50 @@ +// Issues +export const OPEN = [{ id: 1, title: "Login fails", assignee: null }, { id: 3, title: "Crash on save", assignee: null }, { id: 4, title: "Typo in footer", assignee: "dana" }]; +export const CLOSED = [{ id: 2, title: "Old bug", assignee: "eve" }]; +export const OPEN_ROWS = ["#1 Login fails (unassigned)", "#3 Crash on save (unassigned)", "#4 Typo in footer (dana)"]; +export const D1 = { id: 1, title: "Login fails", body: "on Safari only" }; +export const D3 = { id: 3, title: "Crash on save", body: "stack trace attached" }; +export const C3 = [{ author: "dana", text: "seen it too" }]; +export const C1 = []; +/** the open issues loaded */ +export async function loaded(app) { await app.land("fetchIssues:open", OPEN); } +/** the open issues loaded and issue `id` selected with its detail */ +export async function selected(app, id = 3, detail = D3) { await loaded(app); await app.clickText("issue", `#${id} `); await app.land(`fetchIssue:${id}`, detail); } +/** issue 3 selected with its detail and its comments */ +export async function withComments(app) { await selected(app); await app.land("fetchComments:3", C3); } +/** type `title` into the detail's title input and click rename */ +export async function rename(app, title) { await app.type("title-input", title); await app.click("rename"); } +/** type `text` into the comment input and click post */ +export async function post(app, text) { await app.type("comment-input", text); await app.click("post"); } +export const openWithout = id => OPEN.filter(i => i.id !== id); +export const rowsWithout = id => OPEN_ROWS.filter(r => !r.startsWith(`#${id} `)); + +// Help +export const AP = [{ id: 1, name: "apple" }, { id: 2, name: "apricot" }]; +export const APP = [{ id: 1, name: "apple" }, { id: 3, name: "applesauce" }, { id: 4, name: "apple pie" }]; +export const ITEM3 = { id: 3, name: "applesauce", blurb: "stewed apples" }; +export const ITEM4 = { id: 4, name: "apple pie", blurb: "baked with cinnamon" }; +/** type a query and land its answer */ +export async function searched(app, q, hits) { await app.type("query", q); await app.land(`search:${q}`, hits); } + +// Settings +export const ANN = { name: "Ann", theme: "light", density: "compact" }; +export async function settingsLoaded(app) { await app.land("loadSettings:", ANN); } +/** loaded, the name typed, the theme picked, then saved: returns the request's key */ +export async function saving(app, name, theme) { + await settingsLoaded(app); + await app.type("name", name); + if (theme) await app.click(`theme-${theme}`); + await app.click("save"); + return app.only("saveSettings"); +} + +/** open the help or settings tab first */ +export async function open(app, page) { await app.click(`tab-${page}`); } + +// Related help +export const REL3 = [{ id: 7, name: "saving files" }, { id: 8, name: "crash reports" }, { id: 9, name: "autosave" }, { id: 10, name: "backups" }]; +export const REL1 = [{ id: 11, name: "signing in" }]; +export const ITEM8 = { id: 8, name: "crash reports", blurb: "send us the log" }; +/** issue 3 selected with its detail and its related help */ +export async function withRelated(app) { await selected(app); await app.land("search:Crash on save", REL3); } diff --git a/eval/tasks/related/hidden-overlap.mjs b/eval/tasks/related/hidden-overlap.mjs new file mode 100644 index 0000000..fb19f88 --- /dev/null +++ b/eval/tasks/related/hidden-overlap.mjs @@ -0,0 +1,39 @@ +// Round 5 hidden tests: the change's requests overlapping the desk's, and the desk's overlap tests as regressions. +// Never shown to the agent. +import base from "../desk/hidden-overlap.mjs"; +import { AP, ITEM4, ITEM8, REL3, open, selected, withRelated } from "./fixtures.mjs"; + +export default [ + ...base.map(t => ({ ...t, name: `base: ${t.name}` })), + { name: "a related search answering after the issues page was left does not show on the help page", async run(app) { + await selected(app); + await open(app, "help"); + await app.type("query", "ap"); + await app.land("search:Crash on save", REL3); + await app.land("search:ap", AP); + await app.rows("hit", ["apple", "apricot"]); + await app.see("results", "2 results for ap"); + } }, + { name: "selecting another hit after opening a related one shows the other", async run(app) { + await withRelated(app); + await app.clickText("related-hit", "crash reports"); + await app.landIfAsked("search:Crash on save", REL3); + await app.clickText("hit", "backups"); + await app.land("fetchItem:10", { id: 10, name: "backups", blurb: "nightly" }); + await app.landIfAsked("fetchItem:8", ITEM8); + await app.see("item", "nightly"); + await app.notSee("item", "send us the log"); + await app.rows("hit", ["saving files", "crash reports", "autosave", "> backups"]); + } }, + { name: "typing a new query after opening a related hit searches for it", async run(app) { + await withRelated(app); + await app.clickText("related-hit", "crash reports"); + await app.landIfAsked("search:Crash on save", REL3); + await app.type("query", "app"); + await app.land("search:app", [{ id: 1, name: "apple" }, { id: 4, name: "apple pie" }]); + await app.see("results", "2 results for app"); + await app.clickText("hit", "apple pie"); + await app.land("fetchItem:4", ITEM4); + await app.see("item", "baked with cinnamon"); + } }, +]; diff --git a/eval/tasks/related/hidden.mjs b/eval/tasks/related/hidden.mjs new file mode 100644 index 0000000..74c391c --- /dev/null +++ b/eval/tasks/related/hidden.mjs @@ -0,0 +1,58 @@ +// Round 5 hidden tests: the change's own behaviour, and the desk's hidden tests as regressions. Never shown to the +// agent. +import base from "../desk/hidden.mjs"; +import { D1, D3, ITEM8, REL1, REL3, loaded, open, selected, withRelated } from "./fixtures.mjs"; + +export default [ + ...base.map(t => ({ ...t, name: `base: ${t.name}` })), + { name: "an issue with no related help says so", async run(app) { + await selected(app); + await app.land("search:Crash on save", []); + await app.see("related", "no related help"); + await app.rows("related-hit", []); + } }, + { name: "a failed related search says so and leaves the issue", async run(app) { + await selected(app); + await app.fail("search:Crash on save", "offline"); + await app.see("related", "related help failed: offline"); + await app.see("detail", "#3 Crash on save", "stack trace attached"); + } }, + { name: "related help landing out of order shows the selected issue's", async run(app) { + await loaded(app); + await app.clickText("issue", "#3 "); await app.land("fetchIssue:3", D3); + await app.clickText("issue", "#1 "); await app.land("fetchIssue:1", D1); + await app.land("search:Login fails", REL1); + await app.landIfAsked("search:Crash on save", REL3); + await app.rows("related-hit", ["signing in"]); + } }, + { name: "the help page opened from its tab after a related hit starts empty", async run(app) { + await withRelated(app); + await app.clickText("related-hit", "crash reports"); + await app.landIfAsked("search:Crash on save", REL3); + await app.land("fetchItem:8", ITEM8); + await open(app, "issues"); + await open(app, "help"); + await app.hasValue("query", ""); + await app.see("item", "pick a result"); + await app.notSee("results", "crash reports"); + } }, + { name: "the results for a related hit may land after the help page opens", async run(app) { + await withRelated(app); + await app.clickText("related-hit", "autosave"); + await app.see("item", "loading…"); + await app.land("fetchItem:9", { id: 9, name: "autosave", blurb: "every minute" }); + await app.see("item", "every minute"); + await app.landIfAsked("search:Crash on save", REL3); + await app.see("results", "4 results for Crash on save"); + await app.rows("hit", ["saving files", "crash reports", "> autosave", "backups"]); + } }, + { name: "a related item that fails can be retried on the help page", async run(app) { + await withRelated(app); + await app.clickText("related-hit", "crash reports"); + await app.fail("fetchItem:8", "timeout"); + await app.see("item", "failed: timeout"); + await app.click("retry-item"); + await app.land("fetchItem:8", ITEM8); + await app.see("item", "send us the log"); + } }, +]; diff --git a/eval/tasks/related/visible.mjs b/eval/tasks/related/visible.mjs new file mode 100644 index 0000000..7edaebc --- /dev/null +++ b/eval/tasks/related/visible.mjs @@ -0,0 +1,167 @@ +// Visible tests: the agent sees and runs these. The desk's, then the change's. +import { AP, APP, CLOSED, D3, ITEM3, ITEM8, OPEN, OPEN_ROWS, REL3, loaded, open, post, rename, rowsWithout, saving, searched, selected, settingsLoaded, withComments, withRelated } from "./fixtures.mjs"; + +export default [ + { name: "on load: the open issues load", async run(app) { + await app.see("list", "loading open issues"); + await app.see("detail", "select an issue"); + await loaded(app); + await app.rows("issue", OPEN_ROWS); + } }, + { name: "switching the filter loads the closed issues", async run(app) { + await loaded(app); + await app.click("filter-closed"); + await app.see("list", "loading closed issues"); + await app.land("fetchIssues:closed", CLOSED); + await app.rows("issue", ["#2 Old bug (eve)"]); + } }, + { name: "selecting an issue loads its detail", async run(app) { + await loaded(app); + await app.clickText("issue", "#3 "); + await app.see("detail", "loading #3"); + await app.land("fetchIssue:3", D3); + await app.see("detail", "#3 Crash on save", "stack trace attached"); + } }, + { name: "assigning shows the new assignee at once", async run(app) { + await selected(app); + await app.type("user", "ann"); + await app.see("assign", "assign to ann"); + await app.click("assign"); + await app.inAir("assign:3,ann"); + await app.see("list", "#3 Crash on save (ann)"); + await app.land("assign:3,ann", { id: 3, assignee: "ann" }); + await app.see("list", "#3 Crash on save (ann)"); + } }, + { name: "renaming shows the new title at once in the row and the heading", async run(app) { + await selected(app); + await rename(app, "Crash at boot"); + await app.inAir("renameIssue:3,Crash at boot"); + await app.see("list", "#3 Crash at boot (unassigned)"); + await app.see("detail", "#3 Crash at boot"); + } }, + { name: "an accepted rename keeps the new title", async run(app) { + await selected(app); + await rename(app, "Crash at boot"); + await app.land("renameIssue:3,Crash at boot", { id: 3, title: "Crash at boot" }); + await app.see("list", "#3 Crash at boot (unassigned)"); + await app.see("detail", "#3 Crash at boot"); + } }, + { name: "closing removes the row at once and keeps the detail", async run(app) { + await selected(app); + await app.click("close"); + await app.inAir("closeIssue:3"); + await app.rows("issue", rowsWithout(3)); + await app.see("detail", "#3 Crash on save"); + } }, + { name: "an accepted close keeps the row out", async run(app) { + await selected(app); + await app.click("close"); + await app.land("closeIssue:3", { id: 3 }); + await app.rows("issue", rowsWithout(3)); + } }, + { name: "selecting an issue loads its comments", async run(app) { + await selected(app); + await app.see("comments", "loading comments"); + await app.land("fetchComments:3", [{ author: "dana", text: "seen it too" }]); + await app.rows("comment", ["dana: seen it too"]); + } }, + { name: "posting shows the comment at once and keeps it when accepted", async run(app) { + await withComments(app); + await app.type("user", "ann"); + await post(app, "on it"); + await app.inAir("addComment:3,ann,on it"); + await app.rows("comment", ["dana: seen it too", "ann: on it (sending…)"]); + await app.hasValue("comment-input", ""); + await app.land("addComment:3,ann,on it", { author: "ann", text: "on it" }); + await app.landIfAsked("fetchComments:3", [{ author: "dana", text: "seen it too" }, { author: "ann", text: "on it" }]); + await app.rows("comment", ["dana: seen it too", "ann: on it"]); + } }, + { name: "help on open: nothing selected, nothing asked", async run(app) { + await open(app, "help"); + await app.see("item", "pick a result"); + if ((await app.asked()).some(k => /^(search|fetchItem):/.test(k))) throw new Error(`asked ${await app.asked()} on open`); + } }, + { name: "one character is too short and asks nothing", async run(app) { + await open(app, "help"); + await app.type("query", "a"); + await app.see("results", "too short: a"); + await app.notAsked("search:a"); + } }, + { name: "a search shows searching…, then its results", async run(app) { + await open(app, "help"); + await app.type("query", "ap"); + await app.inAir("search:ap"); + await app.see("results", "searching…"); + await app.land("search:ap", AP); + await app.see("results", "2 results for ap"); + await app.notSee("results", "searching…"); + await app.rows("hit", ["apple", "apricot"]); + } }, + { name: "selecting a hit loads its detail", async run(app) { + await open(app, "help"); + await searched(app, "app", APP); + await app.clickText("hit", "applesauce"); + await app.rows("hit", ["apple", "> applesauce", "apple pie"]); + await app.see("item", "loading…"); + await app.land("fetchItem:3", ITEM3); + await app.see("item", "applesauce", "stewed apples"); + } }, + { name: "loads, then shows the saved line and the form", async run(app) { + await open(app, "settings"); + await app.see("load", "Loading settings…"); + await settingsLoaded(app); + await app.eq("saved", "Saved: Ann · light · compact"); + await app.hasValue("name", "Ann"); + await app.eq("name-count", "3/20"); + await app.eq("preview", "Preview: light / compact"); + } }, + { name: "validates the name", async run(app) { + await open(app, "settings"); + await settingsLoaded(app); + await app.type("name", ""); + await app.see("name-error", "Display name is required"); + await app.type("name", "Bartholomew the Magnificent"); + await app.see("name-error", "Display name must be at most 20 characters, not 27"); + await app.eq("name-count", "27/20"); + await app.type("name", "Bea"); + await app.notSee("name-error", "Display name"); + } }, + { name: "a save shows at once, then lands", async run(app) { + await open(app, "settings"); + await saving(app, "Bea", "dark"); + await app.eq("saved", "Saved: Bea · dark · compact (saving…)"); + await app.landOnly("saveSettings", { name: "Bea", theme: "dark", density: "compact" }); + await app.eq("saved", "Saved: Bea · dark · compact"); + } }, + { name: "a failed load says so", async run(app) { + await open(app, "settings"); + await app.fail("loadSettings:", "network down"); + await app.see("load", "Could not load settings: network down"); + } }, + { name: "tabs: settings, then back to issues, starts the issues page again", async run(app) { + await selected(app); + await open(app, "settings"); + await app.see("load", "Loading settings…"); + await app.notSee("list", "Crash on save"); + await open(app, "issues"); + await app.see("list", "loading open issues"); + await app.see("detail", "select an issue"); + } }, + { name: "a selected issue shows its related help", async run(app) { + await selected(app); + await app.see("related", "finding related help"); + await app.land("search:Crash on save", REL3); + await app.rows("related-hit", ["saving files", "crash reports", "autosave"]); + } }, + { name: "opening a related hit shows it on the help page", async run(app) { + await withRelated(app); + await app.clickText("related-hit", "crash reports"); + await app.hasValue("query", "Crash on save"); + await app.landIfAsked("search:Crash on save", REL3); + await app.see("results", "4 results for Crash on save"); + await app.rows("hit", ["saving files", "> crash reports", "autosave", "backups"]); + await app.see("item", "loading…"); + await app.land("fetchItem:8", ITEM8); + await app.see("item", "crash reports", "send us the log"); + } }, +]; diff --git a/eval/tasks/rows/api.d.rx b/eval/tasks/rows/api.d.rx new file mode 100644 index 0000000..f66befd --- /dev/null +++ b/eval/tasks/rows/api.d.rx @@ -0,0 +1,23 @@ +// src/api.js, as .rx sees it. fetchIssues, fetchIssue, fetchComments, fetchLabels, search, fetchItem and loadSettings +// are loaders (safe to ask from an equation); assign, renameIssue, closeIssue, addComment, addLabel, removeLabel and +// saveSettings write. +export type Issue = { id: number; title: string; assignee: string | null } +export type IssueDetail = { id: number; title: string; body: string } +export pure fn fetchIssues(filter: string): Promise<Issue[]> +export pure fn fetchIssue(id: number): Promise<IssueDetail | null> +export fn assign(id: number, user: string): Promise<{ id: number; assignee: string }> +export fn renameIssue(id: number, title: string): Promise<{ id: number; title: string }> +export fn closeIssue(id: number): Promise<{ id: number }> +export type Comment = { author: string; text: string } +export pure fn fetchComments(id: number): Promise<Comment[]> +export fn addComment(id: number, author: string, text: string): Promise<Comment> +export type Hit = { id: number; name: string } +export type Item = { id: number; name: string; blurb: string } +export pure fn search(q: string): Promise<Hit[]> +export pure fn fetchItem(id: number): Promise<Item | null> +export type Settings = { name: string; theme: string; density: string } +export pure fn loadSettings(): Promise<Settings> +export fn saveSettings(s: Settings): Promise<Settings> +export pure fn fetchLabels(id: number): Promise<string[]> +export fn addLabel(id: number, label: string): Promise<string[]> +export fn removeLabel(id: number, label: string): Promise<string[]> diff --git a/eval/tasks/rows/api.d.ts b/eval/tasks/rows/api.d.ts new file mode 100644 index 0000000..0acb5a4 --- /dev/null +++ b/eval/tasks/rows/api.d.ts @@ -0,0 +1,22 @@ +export type Issue = { id: number; title: string; assignee: string | null }; +export type IssueDetail = { id: number; title: string; body: string }; +export function fetchIssues(filter: "open" | "closed"): Promise<Issue[]>; +export function fetchIssue(id: number): Promise<IssueDetail | null>; +export function assign(id: number, user: string): Promise<{ id: number; assignee: string }>; +export function renameIssue(id: number, title: string): Promise<{ id: number; title: string }>; +export function closeIssue(id: number): Promise<{ id: number }>; +export type Comment = { author: string; text: string }; +export function fetchComments(id: number): Promise<Comment[]>; +export function addComment(id: number, author: string, text: string): Promise<Comment>; +export type Hit = { id: number; name: string }; +export type Item = { id: number; name: string; blurb: string }; +export function search(q: string): Promise<Hit[]>; +export function fetchItem(id: number): Promise<Item | null>; +export type Theme = "light" | "dark"; +export type Density = "comfortable" | "compact"; +export type Settings = { name: string; theme: Theme; density: Density }; +export function loadSettings(): Promise<Settings>; +export function saveSettings(s: Settings): Promise<Settings>; +export function fetchLabels(id: number): Promise<string[]>; +export function addLabel(id: number, label: string): Promise<string[]>; +export function removeLabel(id: number, label: string): Promise<string[]>; diff --git a/eval/tasks/rows/api.js b/eval/tasks/rows/api.js new file mode 100644 index 0000000..ac4c81c --- /dev/null +++ b/eval/tasks/rows/api.js @@ -0,0 +1,16 @@ +import { flight } from "./flights.js"; + +export const fetchIssues = filter => flight("fetchIssues", [filter]); +export const fetchIssue = id => flight("fetchIssue", [id]); +export const assign = (id, user) => flight("assign", [id, user]); +export const renameIssue = (id, title) => flight("renameIssue", [id, title]); +export const closeIssue = id => flight("closeIssue", [id]); +export const fetchComments = id => flight("fetchComments", [id]); +export const addComment = (id, author, text) => flight("addComment", [id, author, text]); +export const search = q => flight("search", [q]); +export const fetchItem = id => flight("fetchItem", [id]); +export const loadSettings = () => flight("loadSettings", []); +export const saveSettings = s => flight("saveSettings", [s]); +export const fetchLabels = id => flight("fetchLabels", [id]); +export const addLabel = (id, label) => flight("addLabel", [id, label]); +export const removeLabel = (id, label) => flight("removeLabel", [id, label]); diff --git a/eval/tasks/rows/brief-plain.md b/eval/tasks/rows/brief-plain.md new file mode 100644 index 0000000..f88c332 --- /dev/null +++ b/eval/tasks/rows/brief-plain.md @@ -0,0 +1,31 @@ +# Change request: row actions + +The app in `src/` is an issue desk that already works. `BASE.md` is the brief it was built to, with the changes made +since. Keep everything it does, and let the user assign and close an issue from its row in the list, without +selecting it. + +## The API (`src/api.js`, given; do not change it) + +Everything in `BASE.md`. Nothing new. + +## What to add + +Elements the tests look for carry these `data-testid`s. Text in quotes must appear exactly as written. + +1. **Row buttons.** Next to each row of the list, in the row order, two buttons. They sit outside the row's `issue` + element, which keeps reading exactly as before: + - `row-assign`, reading `assign to <current user>` (`assign to guest`); + - `row-close`, reading `close`, only while the list shows open issues. + Clicking a row button does not select its issue. +2. **Row assign.** Clicking `row-assign` does for that row's issue what the detail's `assign` does: the row shows the + current user as its assignee straight away; it calls `assign(id, user)`; if the server declines, the row goes back + to the assignee it had before; if the server accepts, the row keeps the new assignee. +3. **Row close.** Clicking `row-close` does for that row's issue what the detail's `close` does: the row leaves the + list of open issues straight away (if that issue is selected, the detail keeps showing it); it calls + `closeIssue(id)`; if the server declines, the row comes back where it was; if the server accepts, it stays out. + The `tab-issues` count follows row closes as it follows the detail's. +4. **Row errors.** When the server declines a row action, the issues page shows `row error: <message>` in an + element `row-error` (`row error: declined`). A row error is cleared when the next row action starts. Row actions + do not show or clear the detail's errors, and the detail's actions do not clear a row error. +5. While an issue has more than one assignment out, from its row or its detail, its row shows the newest one + started until that one is answered. diff --git a/eval/tasks/rows/fixtures.mjs b/eval/tasks/rows/fixtures.mjs new file mode 100644 index 0000000..faf70bb --- /dev/null +++ b/eval/tasks/rows/fixtures.mjs @@ -0,0 +1,66 @@ +// Issues +export const OPEN = [{ id: 1, title: "Login fails", assignee: null }, { id: 3, title: "Crash on save", assignee: null }, { id: 4, title: "Typo in footer", assignee: "dana" }]; +export const CLOSED = [{ id: 2, title: "Old bug", assignee: "eve" }]; +export const OPEN_ROWS = ["#1 Login fails (unassigned)", "#3 Crash on save (unassigned)", "#4 Typo in footer (dana)"]; +export const D1 = { id: 1, title: "Login fails", body: "on Safari only" }; +export const D3 = { id: 3, title: "Crash on save", body: "stack trace attached" }; +export const C3 = [{ author: "dana", text: "seen it too" }]; +export const C1 = []; +/** the open issues loaded */ +export async function loaded(app) { await app.land("fetchIssues:open", OPEN); } +/** the open issues loaded and issue `id` selected with its detail */ +export async function selected(app, id = 3, detail = D3) { await loaded(app); await app.clickText("issue", `#${id} `); await app.land(`fetchIssue:${id}`, detail); } +/** issue 3 selected with its detail and its comments */ +export async function withComments(app) { await selected(app); await app.land("fetchComments:3", C3); } +/** type `title` into the detail's title input and click rename */ +export async function rename(app, title) { await app.type("title-input", title); await app.click("rename"); } +/** type `text` into the comment input and click post */ +export async function post(app, text) { await app.type("comment-input", text); await app.click("post"); } +export const openWithout = id => OPEN.filter(i => i.id !== id); +export const rowsWithout = id => OPEN_ROWS.filter(r => !r.startsWith(`#${id} `)); + +// Help +export const AP = [{ id: 1, name: "apple" }, { id: 2, name: "apricot" }]; +export const APP = [{ id: 1, name: "apple" }, { id: 3, name: "applesauce" }, { id: 4, name: "apple pie" }]; +export const ITEM3 = { id: 3, name: "applesauce", blurb: "stewed apples" }; +export const ITEM4 = { id: 4, name: "apple pie", blurb: "baked with cinnamon" }; +/** type a query and land its answer */ +export async function searched(app, q, hits) { await app.type("query", q); await app.land(`search:${q}`, hits); } + +// Settings +export const ANN = { name: "Ann", theme: "light", density: "compact" }; +export async function settingsLoaded(app) { await app.land("loadSettings:", ANN); } +/** loaded, the name typed, the theme picked, then saved: returns the request's key */ +export async function saving(app, name, theme) { + await settingsLoaded(app); + await app.type("name", name); + if (theme) await app.click(`theme-${theme}`); + await app.click("save"); + return app.only("saveSettings"); +} + +/** open the help or settings tab first */ +export async function open(app, page) { await app.click(`tab-${page}`); } + +// Labels +export const L3 = ["bug", "ui"]; +export const L1 = []; +/** issue 3 selected with its detail, comments and labels */ +export async function withLabels(app) { await selected(app); await app.landIfAsked("fetchComments:3", C3); await app.land("fetchLabels:3", L3); } +/** type `label` into the label input and click add-label */ +export async function addLabel(app, label) { await app.type("label-input", label); await app.click("add-label"); } +/** click the remove button of `label` */ +export async function removeLabel(app, label) { await app.clickText("remove-label", `remove ${label}`); } + +// Related help +export const REL3 = [{ id: 7, name: "saving files" }, { id: 8, name: "crash reports" }, { id: 9, name: "autosave" }, { id: 10, name: "backups" }]; +export const REL1 = [{ id: 11, name: "signing in" }]; +export const ITEM8 = { id: 8, name: "crash reports", blurb: "send us the log" }; +/** issue 3 selected with its detail and its related help */ +export async function withRelated(app) { await selected(app); await app.land("search:Crash on save", REL3); } + +// Row actions +/** click the row-assign button of the `n`th row (from 0) */ +export async function rowAssign(app, n) { await app.click("row-assign", n); } +/** click the row-close button of the `n`th row (from 0) */ +export async function rowClose(app, n) { await app.click("row-close", n); } diff --git a/eval/tasks/rows/hidden-overlap.mjs b/eval/tasks/rows/hidden-overlap.mjs new file mode 100644 index 0000000..d934820 --- /dev/null +++ b/eval/tasks/rows/hidden-overlap.mjs @@ -0,0 +1,83 @@ +// Hidden overlap tests of the row actions change (the chain's step 4): row actions overlapping the detail's and the +// list's requests. The chain's overlap file adds the earlier steps' overlap tests as regressions. Never shown to the +// agent. +import { OPEN, OPEN_ROWS, loaded, open, openWithout, rename, rowAssign, rowClose, rowsWithout, selected } from "./fixtures.mjs"; + +export default [ + { name: "rows: a detail assign and a newer row assign on one issue: the newer shows while the older answers", async run(app) { + await selected(app); + await app.type("user", "ann"); + await app.click("assign"); + await app.type("user", "bob"); + await rowAssign(app, 1); + await app.see("list", "#3 Crash on save (bob)"); + await app.land("assign:3,ann", { id: 3, assignee: "ann" }); + await app.see("list", "#3 Crash on save (bob)"); + await app.land("assign:3,bob", { id: 3, assignee: "bob" }); + await app.see("list", "#3 Crash on save (bob)"); + } }, + { name: "rows: a row assign and a newer detail assign on one issue: a declined older one leaves the newer", async run(app) { + await selected(app); + await app.type("user", "ann"); + await rowAssign(app, 1); + await app.type("user", "bob"); + await app.click("assign"); + await app.see("list", "#3 Crash on save (bob)"); + await app.fail("assign:3,ann", "declined"); + await app.see("list", "#3 Crash on save (bob)"); + await app.land("assign:3,bob", { id: 3, assignee: "bob" }); + // the row error may wait until the newer assignment settles (one change, H-11) + await app.landIfAsked("fetchIssues:open", OPEN.map(i => (i.id === 3 ? { ...i, assignee: "bob" } : i))); + await app.see("list", "#3 Crash on save (bob)"); + await app.eq("row-error", "row error: declined"); + await app.notSee("detail", "declined"); + } }, + { name: "rows: a declined row close leaves a rename out on another issue", async run(app) { + await selected(app); + await rename(app, "Crash at boot"); + await rowClose(app, 0); + await app.rows("issue", ["#3 Crash at boot (unassigned)", "#4 Typo in footer (dana)"]); + await app.fail("closeIssue:1", "declined"); + await app.rows("issue", ["#1 Login fails (unassigned)", "#3 Crash at boot (unassigned)", "#4 Typo in footer (dana)"]); + await app.land("renameIssue:3,Crash at boot", { id: 3, title: "Crash at boot" }); + await app.rows("issue", ["#1 Login fails (unassigned)", "#3 Crash at boot (unassigned)", "#4 Typo in footer (dana)"]); + } }, + { name: "rows: a row close and a detail close at once: a declined one leaves the other out and off the count", async run(app) { + await selected(app); + await app.click("close"); + await rowClose(app, 0); + await app.rows("issue", ["#4 Typo in footer (dana)"]); + await app.eq("tab-issues", "Issues (1)"); + await app.fail("closeIssue:1", "declined"); + // the declined close may stay until the other settles (one change, H-11); the other close must not undo + await app.notSee("list", "#3 Crash on save"); + await app.land("closeIssue:3", { id: 3 }); + await app.landIfAsked("fetchIssues:open", openWithout(3)); + await app.rows("issue", ["#1 Login fails (unassigned)", "#4 Typo in footer (dana)"]); + await app.eq("tab-issues", "Issues (2)"); + await app.eq("row-error", "row error: declined"); + await app.notSee("detail", "close error"); + } }, + { name: "rows: open issues that load while a row close is out keep the row out", async run(app) { + await loaded(app); + await rowClose(app, 1); + await app.click("filter-closed"); + await app.landOnly("fetchIssues", []); + await app.click("filter-open"); + await app.land("fetchIssues:open", OPEN); + await app.rows("issue", rowsWithout(3)); + await app.eq("tab-issues", "Issues (2)"); + await app.land("closeIssue:3", { id: 3 }); + await app.rows("issue", rowsWithout(3)); + } }, + { name: "rows: a row action that answers after the issues page was left changes nothing on its return", async run(app) { + await loaded(app); + await rowAssign(app, 1); + await open(app, "help"); + await app.fail("assign:3,guest", "declined"); + await open(app, "issues"); + await app.land("fetchIssues:open", OPEN); + await app.rows("issue", OPEN_ROWS); + await app.notSee("row-error", "row error"); + } }, +]; diff --git a/eval/tasks/rows/hidden.mjs b/eval/tasks/rows/hidden.mjs new file mode 100644 index 0000000..f51df42 --- /dev/null +++ b/eval/tasks/rows/hidden.mjs @@ -0,0 +1,75 @@ +// Hidden tests of the row actions change (the chain's step 4): its own behaviour. The chain's hidden file adds the +// earlier steps' hidden tests as regressions. Never shown to the agent. +import { OPEN_ROWS, loaded, rowAssign, rowClose, rowsWithout, selected } from "./fixtures.mjs"; + +export default [ + { name: "rows: a declined row assign reverts the row and shows the row error", async run(app) { + await loaded(app); + await rowAssign(app, 1); + await app.see("list", "#3 Crash on save (guest)"); + await app.fail("assign:3,guest", "declined"); + await app.rows("issue", OPEN_ROWS); + await app.eq("row-error", "row error: declined"); + } }, + { name: "rows: a declined row close brings the row back in place, the count back, and shows the row error", async run(app) { + await loaded(app); + await rowClose(app, 1); + await app.rows("issue", rowsWithout(3)); + await app.fail("closeIssue:3", "declined"); + await app.rows("issue", OPEN_ROWS); + await app.eq("tab-issues", "Issues (3)"); + await app.eq("row-error", "row error: declined"); + } }, + { name: "rows: the next row action clears the row error at once", async run(app) { + await loaded(app); + await rowClose(app, 1); + await app.fail("closeIssue:3", "declined"); + await app.see("row-error", "row error: declined"); + await rowAssign(app, 0); + await app.inAir("assign:1,guest"); + await app.notSee("row-error", "row error"); + } }, + { name: "rows: a row close on the selected issue leaves its detail", async run(app) { + await selected(app); + await rowClose(app, 1); + await app.rows("issue", rowsWithout(3)); + await app.see("detail", "#3 Crash on save", "stack trace attached"); + await app.land("closeIssue:3", { id: 3 }); + await app.see("detail", "#3 Crash on save"); + } }, + { name: "rows: row errors and detail errors leave each other alone", async run(app) { + await selected(app); + await app.click("assign"); + await app.fail("assign:3,guest", "no rights"); + await app.see("detail", "no rights"); + await rowClose(app, 0); + await app.fail("closeIssue:1", "declined"); + await app.eq("row-error", "row error: declined"); + await app.see("detail", "no rights"); + await app.notSee("detail", "close error"); + await app.click("rename"); // nothing typed: no rename starts + await app.type("title-input", "Crash at boot"); + await app.click("rename"); + await app.eq("row-error", "row error: declined"); + } }, + { name: "rows: a row button does not select, even after another issue was selected", async run(app) { + await selected(app); + await rowAssign(app, 0); + await app.see("detail", "#3 Crash on save"); + await app.notAsked("fetchIssue:1"); + await app.land("assign:1,guest", { id: 1, assignee: "guest" }); + await app.see("list", "#1 Login fails (guest)"); + await app.see("detail", "#3 Crash on save"); + } }, + { name: "rows: an accepted row close survives switching filters and back", async run(app) { + await loaded(app); + await rowClose(app, 0); + await app.land("closeIssue:1", { id: 1 }); + await app.click("filter-closed"); + await app.land("fetchIssues:closed", [{ id: 1, title: "Login fails", assignee: null }, { id: 2, title: "Old bug", assignee: "eve" }]); + await app.click("filter-open"); + await app.land("fetchIssues:open", [{ id: 3, title: "Crash on save", assignee: null }, { id: 4, title: "Typo in footer", assignee: "dana" }]); + await app.rows("issue", rowsWithout(1)); + await app.eq("tab-issues", "Issues (2)"); + } }, +]; diff --git a/eval/tasks/rows/visible.mjs b/eval/tasks/rows/visible.mjs new file mode 100644 index 0000000..768bcfc --- /dev/null +++ b/eval/tasks/rows/visible.mjs @@ -0,0 +1,41 @@ +// Visible tests of the row actions change (the chain's step 4). The chain's visible file adds the earlier steps'. +import { CLOSED, OPEN_ROWS, loaded, rowAssign, rowClose, rowsWithout } from "./fixtures.mjs"; + +export default [ + { name: "rows: each row has an assign and a close button, and its text is unchanged", async run(app) { + await loaded(app); + await app.rows("issue", OPEN_ROWS); + await app.rows("row-assign", ["assign to guest", "assign to guest", "assign to guest"]); + await app.rows("row-close", ["close", "close", "close"]); + await app.type("user", "ann"); + await app.rows("row-assign", ["assign to ann", "assign to ann", "assign to ann"]); + } }, + { name: "rows: a row assign shows the assignee at once and keeps it, without selecting the issue", async run(app) { + await loaded(app); + await app.type("user", "ann"); + await rowAssign(app, 1); + await app.inAir("assign:3,ann"); + await app.see("list", "#3 Crash on save (ann)"); + await app.see("detail", "select an issue"); + await app.notAsked("fetchIssue:3"); + await app.land("assign:3,ann", { id: 3, assignee: "ann" }); + await app.see("list", "#3 Crash on save (ann)"); + } }, + { name: "rows: a row close removes the row at once and keeps it out", async run(app) { + await loaded(app); + await rowClose(app, 1); + await app.inAir("closeIssue:3"); + await app.rows("issue", rowsWithout(3)); + await app.eq("tab-issues", "Issues (2)"); + await app.land("closeIssue:3", { id: 3 }); + await app.rows("issue", rowsWithout(3)); + await app.eq("tab-issues", "Issues (2)"); + } }, + { name: "rows: closed issues have no row close button", async run(app) { + await loaded(app); + await app.click("filter-closed"); + await app.land("fetchIssues:closed", CLOSED); + await app.rows("row-assign", ["assign to guest"]); + await app.rows("row-close", []); + } }, +]; diff --git a/guide/async.md b/guide/async.md index 71e201e..98077fe 100644 --- a/guide/async.md +++ b/guide/async.md @@ -13,7 +13,7 @@ want one of the moves below. ## 1. Reading from the server: an equation ``` -forecast = city == "" ? null : len(city) < 3 ? throw TooShort(city) : await fetchForecast(city) +forecast = city == "" ? null : city.length < 3 ? throw TooShort(city) : await fetchForecast(city) ``` - It is asked again whenever its inputs change. An answer to an older question is thrown away when it lands @@ -77,7 +77,7 @@ export source todos: Todo[] key id = await loadTodos() export action markDone(id: number) { try { - let i = indexOf(todos, t => t.id == id) // from the prelude; -1 when absent + let i = todos.findIndex(t => t.id == id) // -1 when absent optimistic todos[i].done := true // this row only await saveDone(id) refresh todos // the server's list confirms the guess @@ -90,6 +90,30 @@ A guess on `todos[i].done` belongs to its own action: another action's guess on declined one stays on screen until the others end. After the `await`, re-ask the list (`refresh todos`) rather than writing the row: a plain write to a list that is guessed by row is not supported yet. +### Guessing data that depends on a selection + +A guess on a value whose question reads a selection holds the selection with it: while the action runs, choosing +something else waits for the server, and the screen stays on the old choice until the action ends (F-1). Keep the +pending items in a source of their own, with a constant question, and show them beside the loaded ones: + +``` +source photo: number := 1 +tags = await loadTags(photo) +source adding: { photo: number; tag: string }[] = [] +shownAdding = adding.filter(a => a.photo == photo) // shown after `tags` + +export action addTag(p: number, tag: string) { + try { + optimistic adding := [...adding, { photo: p, tag: tag }] + await saveTag(p, tag) + refresh tags // the server's list now has the tag; the guess goes with the action + } catch (e) { … } +} +``` + +Changing `photo` while a tag is saving shows the new photo's tags at once, and the pending tag only under its own +photo. + ## 4. Things that look like bugs and aren't - **A plain write in an action doesn't show until the server answers.** That's the hold. Use `optimistic` or @@ -113,7 +137,26 @@ local state: typing, toggles, selection. A run never sees its own writes: after `x := v`, reading `x` in the same run gives the old value (W-4), and the compiler refuses it (`STALE_OWN_READ`). Keep the value in a local: `let n = count + 1; count := n; log(n)`. -## 6. Forms +## 6. Effects: acting on a value as it changes + +`effect (a, b) { … }` runs its block with the inputs' values as `$0`, `$1`. Prefer an equation for anything that is +a value; an effect is for sending a value somewhere else (a parent's `on…` handler, `log`, a JS module). + +- **When it runs**: once when its component appears, then after any change in which an input's value differs from + the value the block last received (EF-2). The same value again is not a change. It runs after everything has + settled, against what is on screen, and its writes are a new change of their own. +- **Loading inputs**: an input that is loading (a first load, or a re-ask after its inputs changed) is pending, and + the block does not run until the answer lands (R-2). The effect doesn't hold the screen while it waits. +- **Failing inputs**: give an input that can fail a `catch` (`effect (x catch (e) => fallback) { … }`), so the block + gets a plain value it can test for. +- **Guesses**: an input computed from an `optimistic` write fires as soon as the guess shows, and again with the old + value if the guess reverts (EF-2). It doesn't wait for the action to end. +- **Lifetime**: an effect lives as long as its component. When the component goes away the effect stops, and its + cleanup (a function the block returns) runs; nothing it would have seen later reaches it. State that must outlive + a page belongs in a component that stays (pass it down, report changes up with an `on…` handler). +- **Order**: two effects that fire in the same change run in no promised order (EF-6). + +## 7. Forms A draft seeded once from loaded data is a source in the form's component, seeded with `peek`: `source name := peek(props.initial.name)`. It belongs to the user from then on; a save never resets it. diff --git a/guide/language.md b/guide/language.md new file mode 100644 index 0000000..e563b04 --- /dev/null +++ b/guide/language.md @@ -0,0 +1,119 @@ +# .rx on one page + +ReactiveScript is TypeScript's expressions and JSX, plus a few reactive forms spelled with words. It compiles to +Solid 2.0. This page is the whole working language; `async.md` is how to talk to a server. The rules cited (E-1, +AC-3, …) are in `spec/`, and you don't need them to write code. + +## Modules + +``` +import pure { fetchUser } from "./api" // a loader: callable anywhere, re-asked when its inputs change +import { saveUser } from "./api" // io: callable only in imperative code (handlers, actions, effects) +import { Avatar } from "./Avatar.rx" // another .rx module +export default component App() { … } // the entry module's default component is mounted +``` + +A file's top level is imports, exports and declarations. A `source` at the top level is app state; inside a +component it is that instance's state. Exported sources are read-only to importers: export actions that write them. + +## Declarations + +``` +source count: number := 0 // state; written only with `:=` in imperative code +source user = await fetchUser(id) // state with a question: re-asked when `id` changes, can take a guess +double = count * 2 // an equation: always equal to its right side, never written +label = `${user.name} (${count})` +rows key id = items // a keyed collection (rows keep identity by `id`) +fn area(w: number, h: number): number => w * h // a pure function +action save(n: string) { … } // writes and awaits (async.md) +component Row(props: { item: Item }) { … } +error NotFound(id: number) => `no issue ${id}` // an error class; `NotFound(3)` makes one +type Item = { id: number; name: string } +``` + +There is no `let` at the top level or in a component: a value that changes is a `source`, and one computed from +others is an equation. Equations are lazy and exact: an arm of `c ? a : b` that isn't taken isn't evaluated. + +## Expressions + +TypeScript's, minus assignment operators (`=`, `+=`, `++`), `this`, `function`, `async` and `new` on reactive +values. Arrays and strings have their usual methods: `xs.filter(f)`, `xs.map(f)`, `xs.findIndex(f)`, `xs.some(f)`, +`xs.length`, `s.trim()`. Added forms: + +| Form | Meaning | +| --- | --- | +| `await e` | the answer to `e` (in an equation: re-asked when inputs change, stale answers dropped) | +| `pending? x` | `true` while `x` is being re-asked or written by an action in flight | +| `latest x` | the value `x` is moving toward (what was typed, before the answer lands) | +| `peek(x)` | `x` read once, without depending on it | +| `e catch (err) => alt` | `alt` when `e` fails | +| `c ? a : throw NotFound(id)` | a failure as a value of an arm | +| `use name` / `provide name = e` | context: the nearest provision above | + +## Markup + +A component body is declarations, then markup. JSX is verbatim; control flow is braced: + +``` +component IssueList(props: { issues: Issue[] }) { + source open := true + <section> + <button data-testid="toggle" onClick={() => { open := !open }}>toggle</button>{" "} + {if open { + <ul> + {for it in props.issues { + <li data-testid="issue">#{it.id} {it.title}</li> + }} + </ul> + } else { + <p>hidden</p> + }} + {loading fallback { <p>loading…</p> } { <Details /> }} + {errored fallback(e) { <p>failed: {e.message}</p> } { <Details /> }} + </section> +} +``` + +- `{if c { … } else { … }}`, `{for x in xs { … }}`, `{loading … { … }}`, `{errored … { … }}` inside an element; + the same without the outer braces at a block's top level. Each body may start with its own declarations. +- Text follows JSX: line breaks between elements are not text. Write `{" "}` for a space between elements. +- Events: `onClick={() => { … }}`, `onInput={(e) => { name := e.target.value }}`. An input shows a source with + `value={name}`. + +## Imperative code + +Handlers, `action` bodies and `effect` apply blocks: + +``` +<button onClick={() => { count := count + 1 }}>+</button> + +effect (count) { log($0) } // runs after `count` changes; `$0` is its value (async.md §6) +``` + +- A write is `x := v`, `x.field := v`, `xs[i].done := v`, or functional `x := old => …` (applied to the slot as it + stands at the write). +- `let`/`const`, `if`, `try`/`catch`/`finally`, `return`, `throw` work as in TypeScript (no `for`/`while` loops: + use array methods). +- Browser and JS globals (`console`, `document`, `window`) are not in scope. `log(…)` prints; anything else from + JS comes in through an import from a `.js` module. +- Reading a source gives its value at the start of the run: after `x := v`, `x` still reads the old value. Keep the + new value in a local (`let n = count + 1; count := n`). +- `await` is a statement: `await save(x)` or `let r = await load(x)`. A handler that writes and awaits is an action, + with the rules in `async.md`. + +## Components + +``` +component Card(props: { title: string; children: View }) { + source open := false + <div onClick={() => { open := !open }}> + <h2>{props.title}</h2> + {if open { {props.children} }} + </div> +} +``` + +`props.x` stays live: don't copy it into a source unless you mean a one-time seed (`source draft := peek(props.x)`). + +A prop that takes a handler is named `on…` and typed as a function: `props: { onPick: (id: number) => void }`, passed +as `<Row onPick={id => { selected := id }} />` and called from the component's own handlers (`props.onPick(id)`). diff --git a/ref/SOLID_NEXT b/ref/SOLID_NEXT index 15f9aba..a0f1d85 100644 --- a/ref/SOLID_NEXT +++ b/ref/SOLID_NEXT @@ -1 +1 @@ -ac28157aba6d03b806f0043bce70a87d515e953e +5e68925cc2fd216a9fb06345019e66d5da5c523c diff --git a/ref/package-lock.json b/ref/package-lock.json index 2d4ea9e..37ac517 100644 --- a/ref/package-lock.json +++ b/ref/package-lock.json @@ -19,7 +19,7 @@ }, "../../solid-next/packages/compiler": { "name": "@solidjs/compiler", - "version": "2.0.0-rc.19", + "version": "2.0.0-rc.20", "license": "MIT", "devDependencies": { "@emnapi/core": "^1.11.3", @@ -29,17 +29,17 @@ "emnapi": "^1.11.3" }, "optionalDependencies": { - "@solidjs/compiler-darwin-arm64": "2.0.0-rc.19", - "@solidjs/compiler-darwin-x64": "2.0.0-rc.19", - "@solidjs/compiler-linux-arm64-gnu": "2.0.0-rc.19", - "@solidjs/compiler-linux-x64-gnu": "2.0.0-rc.19", - "@solidjs/compiler-wasm32-wasi": "2.0.0-rc.19", - "@solidjs/compiler-win32-x64-msvc": "2.0.0-rc.19" + "@solidjs/compiler-darwin-arm64": "2.0.0-rc.20", + "@solidjs/compiler-darwin-x64": "2.0.0-rc.20", + "@solidjs/compiler-linux-arm64-gnu": "2.0.0-rc.20", + "@solidjs/compiler-linux-x64-gnu": "2.0.0-rc.20", + "@solidjs/compiler-wasm32-wasi": "2.0.0-rc.20", + "@solidjs/compiler-win32-x64-msvc": "2.0.0-rc.20" } }, "../../solid-next/packages/signals": { "name": "@solidjs/signals", - "version": "2.0.0-rc.19", + "version": "2.0.0-rc.20", "license": "MIT", "devDependencies": { "@codspeed/vitest-plugin": "^5.4.0", @@ -62,10 +62,10 @@ }, "../../solid-next/packages/solid": { "name": "solid-js", - "version": "2.0.0-rc.19", + "version": "2.0.0-rc.20", "license": "MIT", "dependencies": { - "@solidjs/signals": "^2.0.0-rc.19", + "@solidjs/signals": "^2.0.0-rc.20", "csstype": "^3.1.0", "seroval": "1.7.0", "seroval-plugins": "1.7.0" @@ -76,7 +76,7 @@ }, "../../solid-next/packages/universal": { "name": "@solidjs/universal", - "version": "2.0.0-rc.19", + "version": "2.0.0-rc.20", "license": "MIT", "devDependencies": { "solid-js": "workspace:*" @@ -85,12 +85,12 @@ "node": ">=22.12.0" }, "peerDependencies": { - "solid-js": "^2.0.0-rc.19" + "solid-js": "^2.0.0-rc.20" } }, "../../solid-next/packages/web": { "name": "@solidjs/web", - "version": "2.0.0-rc.19", + "version": "2.0.0-rc.20", "license": "MIT", "dependencies": { "seroval": "1.7.0", @@ -106,7 +106,7 @@ "node": ">=22.12.0" }, "peerDependencies": { - "solid-js": "^2.0.0-rc.19" + "solid-js": "^2.0.0-rc.20" } }, "node_modules/@esbuild/aix-ppc64": { diff --git a/ref/src/check.ts b/ref/src/check.ts index c16a7e5..c3549df 100644 --- a/ref/src/check.ts +++ b/ref/src/check.ts @@ -696,10 +696,11 @@ class Checker { } } - /** AC-1c, AC-1d over one imperative run (ref/src/infer.ts). */ + /** AC-1c, AC-1d, AC-1e over one imperative run (ref/src/infer.ts). */ private runChecks(stmts: Stmt[], action: boolean, bound: string[], inferred = false) { for (const f of runFindings(stmts, action, bound, inferred)) { if (f.code === "HELD_WRITE") this.err("HELD_WRITE", "AC-1c", `${f.name} := … comes before an \`await\` in a body that is an action because it writes and awaits (AC-1b), so it is held until the body ends (AC-1): for state that shows while the work runs, \`optimistic ${f.name} := …\` (a source with a question, ST-5c); for a reset that lands now, \`onFlush(() => { ${f.name} := … })\` (EF-8); to hold it on purpose, declare the action (\`action name() { … }\`); or write it after the \`await\``); + else if (f.code === "STALE_WRITE_BACK") this.err("STALE_WRITE_BACK", "AC-1e", `${f.name} := … after an \`await\` is computed from ${f.name} as it was read before that \`await\`: whatever changed ${f.name} in between (another action, a reload) is overwritten — write functionally (\`${f.name} := v => …\`, W-6), which applies to ${f.name} as it stands at the write, or guess it (\`optimistic\`, AC-3) and let the answer replace the guess`); else this.err("STALE_OWN_READ", "AC-1d", `${f.name} is read after this change wrote it: the read sees the committed value, not the write (W-4) — keep the value you wrote in a local (\`let v = …; ${f.name} := v\`) and read that, or write functionally (\`${f.name} := v => …\`, W-6)`); } } diff --git a/ref/src/infer.ts b/ref/src/infer.ts index 5278e96..ebd6a94 100644 --- a/ref/src/infer.ts +++ b/ref/src/infer.ts @@ -1,7 +1,8 @@ -// 06 AC-1b…AC-1d: what the compiler reads off an imperative body. Which arrows are actions without the keyword (AC-1b), and -// the two checks every imperative run gets: a plain write an action holds across an `await` (HELD_WRITE, AC-1c), and a read -// of a source the same change already wrote (STALE_OWN_READ, AC-1d). Solid sees neither: a TypeScript body's promises and -// reads are not visible to it, which is why it asks for `action(function* …)` and lets a stale read through. +// 06 AC-1b…AC-1e: what the compiler reads off an imperative body. Which arrows are actions without the keyword (AC-1b), and +// the checks every imperative run gets: a plain write an action holds across an `await` (HELD_WRITE, AC-1c), a read of a +// source the same change already wrote (STALE_OWN_READ, AC-1d), and a write after an `await` computed from the same source +// as read before it (STALE_WRITE_BACK, AC-1e). Solid sees none of them: a TypeScript body's promises and reads are not +// visible to it, which is why it asks for `action(function* …)` and lets a stale read or a lost update through. import type { Expr, Stmt } from "./ast.ts"; export type Trigger = "optimistic" | "affects" | "write+await"; @@ -42,23 +43,28 @@ export function inferredAction(stmts: Stmt[]): Trigger | null { } export const triggerText = (t: Trigger): string => t === "write+await" ? "it writes and awaits" : `it has \`${t}\``; -export type Finding = { code: "HELD_WRITE" | "STALE_OWN_READ"; name: string }; +export type Finding = { code: "HELD_WRITE" | "STALE_OWN_READ" | "STALE_WRITE_BACK"; name: string }; type Path = string[]; // keys from the source; "*" a computed key -type State = { written: Map<string, Path[]>; held: Set<string>; dead: boolean }; -const clone = (s: State): State => ({ written: new Map([...s.written].map(([k, v]) => [k, [...v]])), held: new Set(s.held), dead: s.dead }); +type Dep = { k: string; path: Path; epoch: number }; // AC-1e: a source read, and the awaits passed when it was read +// taint: the source reads each local's value was computed from; epoch: the awaits passed on this path (the most, after a merge) +type State = { written: Map<string, Path[]>; held: Set<string>; dead: boolean; taint: Map<string, Dep[]>; epoch: number }; +const clone = (s: State): State => ({ written: new Map([...s.written].map(([k, v]) => [k, [...v]])), held: new Set(s.held), dead: s.dead, taint: new Map([...s.taint].map(([k, v]) => [k, [...v]])), epoch: s.epoch }); const merge = (...ss: State[]): State => { const live = ss.filter(s => !s.dead); if (!live.length) return { ...clone(ss[0]), dead: true }; - const out: State = { written: new Map(), held: new Set(), dead: false }; - for (const s of live) { for (const [k, v] of s.written) out.written.set(k, [...(out.written.get(k) ?? []), ...v]); s.held.forEach(h => out.held.add(h)); } + const out: State = { written: new Map(), held: new Set(), dead: false, taint: new Map(), epoch: 0 }; + for (const s of live) { + for (const [k, v] of s.written) out.written.set(k, [...(out.written.get(k) ?? []), ...v]); s.held.forEach(h => out.held.add(h)); + for (const [k, v] of s.taint) out.taint.set(k, [...(out.taint.get(k) ?? []), ...v]); out.epoch = Math.max(out.epoch, s.epoch); + } return out; }; const overlaps = (a: Path, b: Path): boolean => { for (let i = 0; i < Math.min(a.length, b.length); i++) if (a[i] !== b[i] && a[i] !== "*" && b[i] !== "*") return false; return true; }; -/** AC-1c and AC-1d over one imperative run. `action`: the body is one change across its awaits (AC-1); a plain run's change +/** AC-1c, AC-1d and AC-1e over one imperative run. `action`: the body is one change across its awaits (AC-1); a plain run's change * ends at each `await` (07 §5). `inferred`: an action by AC-1b, the only kind HELD_WRITE applies to — a declared `action` * holds an early write on purpose (a transition: `action go() { id := 2; await prefetch() }`, S-solid-20). `bound`: the * run's parameters. In source order, per path: a plain write that reaches an `await` in an inferred action is HELD_WRITE; - * a read of a source this change wrote, at an overlapping key, is STALE_OWN_READ. A guess is not a held write (it shows now, H-11) but is a write a read does not see (H-10). A + * a read of a source this change wrote, at an overlapping key, is STALE_OWN_READ; a plain write after an `await` whose value comes from a read of the same source (at an overlapping key) taken before that `await`, directly or through a local, is STALE_WRITE_BACK. A guess is not a held write (it shows now, H-11) but is a write a read does not see (H-10). A * functional write (`x := v => …`, W-6) reads nothing now; a closure's body runs later, unless it is a local closure called * here; `pending?` and `latest` are probes, not snapshots. Each finding once per name. */ export function runFindings(stmts: Stmt[], action: boolean, bound: string[] = [], inferred = false): Finding[] { @@ -81,7 +87,7 @@ export function runFindings(stmts: Stmt[], action: boolean, bound: string[] = [] case "call": { const c = calledClosure(x, local); x.args.forEach(read); - if (c) { if (!depth.has(c)) { const inner = new Set(sh); c.params.forEach(p => inner.add(p)); const after = walk(c.stmts!, { ...clone(st), dead: false }, inner, local, new Set([...depth, c])); st.written = after.written; st.held = after.held; } return; } + if (c) { if (!depth.has(c)) { const inner = new Set(sh); c.params.forEach(p => inner.add(p)); const after = walk(c.stmts!, { ...clone(st), dead: false }, inner, local, new Set([...depth, c])); st.written = after.written; st.held = after.held; st.epoch = after.epoch; } return; } read(x.f); return; } default: @@ -91,21 +97,42 @@ export function runFindings(stmts: Stmt[], action: boolean, bound: string[] = [] } } }; + // AC-1e: the source reads a value is computed from now — read directly (at this epoch) or through a local's taint. A + // closure's body runs later; an awaited answer is the server's, not a snapshot of a source. + const deps = (e: Expr, out: Dep[] = []): Dep[] => { + const path: Path = []; let x = e; + while (x.k === "member" || x.k === "index") { if (x.k === "member") path.unshift(x.p); else { path.unshift(x.i.k === "lit" && typeof x.i.v === "string" ? x.i.v : "*"); deps(x.i, out); } x = x.o; } + if (x.k === "id" && sh.has(x.name)) { out.push(...(st.taint.get(x.name) ?? [])); return out; } + if (x.k === "id" || x.k === "use") { out.push({ k: key(x.name, x.k === "use"), path, epoch: st.epoch }); return out; } + if (x !== e) return deps(x, out); + switch (x.k) { + case "fn": case "serverfn": case "draft": case "pending": case "latest": case "lit": case "pre": case "peek": case "stylemap": case "await": return out; + default: + for (const v of Object.values(x as object)) { + if (Array.isArray(v)) v.forEach(y => { if (y && typeof y === "object") { if ("k" in y) deps(y as Expr, out); else if ("v" in y) deps((y as { v: Expr }).v, out); else if ("spread" in y) deps((y as { spread: Expr }).spread, out); } }); + else if (v && typeof v === "object" && "k" in (v as object)) deps(v as Expr, out); + } + return out; + } + }; const onAwait = () => { if (action) { st.held.forEach(h => report("HELD_WRITE", h)); st.held.clear(); } else st.written.clear(); }; for (const s of ss) { if (st.dead) break; switch (s.k) { case "let": case "expr": { read(s.e); - if (stmtAwait(s)) onAwait(); - if (s.k === "let") { const c = closureOf(s); sh.add(s.name); if (c) local.set(s.name, c); else local.delete(s.name); } + const d = stmtAwait(s) ? [] : deps(s.e); + if (stmtAwait(s)) { st.epoch++; onAwait(); } + if (s.k === "let") { const c = closureOf(s); sh.add(s.name); st.taint.set(s.name, d); if (c) local.set(s.name, c); else local.delete(s.name); } break; } case "write": case "optimistic": { for (const p of s.target.path) if (typeof p !== "string") read(p); if (s.e.k !== "fn") read(s.e); const k = key(s.target.name, s.target.ctx); - st.written.set(k, [...(st.written.get(k) ?? []), s.target.path.map(p => typeof p === "string" ? p : p.k === "lit" && typeof p.v === "string" ? p.v : "*")]); + const wpath = s.target.path.map(p => typeof p === "string" ? p : p.k === "lit" && typeof p.v === "string" ? p.v : "*"); + if (s.k === "write" && s.e.k !== "fn" && deps(s.e).some(d => d.k === k && d.epoch < st.epoch && overlaps(d.path, wpath))) report("STALE_WRITE_BACK", k); + st.written.set(k, [...(st.written.get(k) ?? []), wpath]); if (s.k === "write" && inferred) st.held.add(k); break; } @@ -126,6 +153,7 @@ export function runFindings(stmts: Stmt[], action: boolean, bound: string[] = [] } return st; }; - walk(stmts, { written: new Map(), held: new Set(), dead: false }, new Set(bound), new Map(), new Set()); + walk(stmts, { written: new Map(), held: new Set(), dead: false, taint: new Map(), epoch: 0 }, new Set(bound), new Map(), new Set()); return out; } + diff --git a/ref/src/parse.ts b/ref/src/parse.ts index 97280ef..d669669 100644 --- a/ref/src/parse.ts +++ b/ref/src/parse.ts @@ -48,10 +48,13 @@ class Cursor { } /** A TypeScript type, kept as text: balanced up to a depth-0 stop (`=`, `:=`, `,`, `)`, `=>`, `{` when `braceStops`). 07 §2. */ -function parseType(c: Cursor, braceStops = false): string { +/** A type's text, up to where the declaration goes on. `=>` inside brackets is a function type, as it is anywhere in a + * type alias (`arrows`); elsewhere at the top it starts a body (`fn f(): T => …`). */ +function parseType(c: Cursor, braceStops = false, arrows = false): string { const start = c.i; let depth = 0; while (!c.eof()) { const ch = c.peek(); + if (c.startsWith("=>") && (depth > 0 || arrows)) { c.i += 2; continue; } if (depth === 0) { if (c.startsWith("//") || c.startsWith("=>") || c.startsWith(":=") || ch === "=" || ch === "," || ch === ")" || ch === ";" || ch === "\n" || (braceStops && ch === "{")) break; } @@ -102,7 +105,7 @@ export function parseDeclarations(src: string): JsDecl[] { const line = c.line; if (!c.keyword("export")) c.fail("a declaration file declares exports: `export pure fn …`, `export fn …`, `export error …`, `export type …`"); c.ws(); - if (c.startsWith("type") || c.startsWith("interface")) { const t0 = c.i; c.keyword("type") || c.keyword("interface"); c.ws(); const name = c.ident() ?? c.fail("type name"); c.ws(); if (c.eat("=")) { c.ws(); parseType(c); } else if (c.startsWith("{")) { let depth = 0; do { if (c.peek() === "{") depth++; if (c.peek() === "}") depth--; c.i++; } while (!c.eof() && depth); } out.push({ kind: "type", name, line, text: c.src.slice(t0, c.i) }); continue; } + if (c.startsWith("type") || c.startsWith("interface")) { const t0 = c.i; c.keyword("type") || c.keyword("interface"); c.ws(); const name = c.ident() ?? c.fail("type name"); c.ws(); if (c.eat("=")) { c.ws(); parseType(c, false, true); } else if (c.startsWith("{")) { let depth = 0; do { if (c.peek() === "{") depth++; if (c.peek() === "}") depth--; c.i++; } while (!c.eof() && depth); } out.push({ kind: "type", name, line, text: c.src.slice(t0, c.i) }); continue; } if (c.keyword("error")) { c.ws(); const name = c.ident() ?? c.fail("error class name"); out.push({ kind: "error", name, line }); continue; } const side = c.keyword("server") ? "server" as const : c.keyword("client") ? "client" as const : undefined; c.ws(); // 12 SF-5: runs on one side only const pure = c.keyword("pure"); c.ws(); @@ -151,7 +154,7 @@ function parseDecl(c: Cursor): Decl { if (/^(type|interface)\b/.test(c.src.slice(c.i))) { // a TS type declaration: kept as text, not a binding const t0 = c.i; c.keyword("type") || c.keyword("interface"); c.ws(); const name = c.ident() ?? c.fail("type name"); c.ws(); - if (c.eat("=")) { c.ws(); parseType(c); } else { c.expect("{"); let depth = 1; while (!c.eof() && depth) { if (c.peek() === "{") depth++; if (c.peek() === "}") depth--; c.i++; } } + if (c.eat("=")) { c.ws(); parseType(c, false, true); } else { c.expect("{"); let depth = 1; while (!c.eof() && depth) { if (c.peek() === "{") depth++; if (c.peek() === "}") depth--; c.i++; } } return { k: "typedecl", name, line, text: c.src.slice(t0, c.i) }; } if (c.keyword("source")) { diff --git a/scenarios/S-infer-6.md b/scenarios/S-infer-6.md new file mode 100644 index 0000000..2e0888a --- /dev/null +++ b/scenarios/S-infer-6.md @@ -0,0 +1,85 @@ +# S-infer-6 — a write after an await is not built from a read before it + +Rules: AC-1e, W-6, AC-1, H-2. + +## Program + +``` +export default component App() { + source log := "" + action add(x: string) { + let r = await fetch(x) catch (e) => "" + log := v => v + x + } + + <button onClick={() => { add("a") }}>log: {log}</button> +} +``` + +## Frames + +| instant | action | frame | +| ------- | --------------------- | --------- | +| 0 | mount | `log: ` | +| 1 | handler `add("a")` | `log: ` | +| 2 | handler `add("b")` | `log: ` | +| 3 | resolve "b" "ok" | `log: b` | +| 4 | resolve "a" "ok" | `log: ba` | + +Two adds are out at once. Each appends to `log` as it stands at its write +(W-6), so the one that lands second keeps the first one's work. + +## Rejected variants + +``` +-- reject: STALE_WRITE_BACK +export default component App() { + source log := "" + action add(x: string) { + let before = log + let r = await fetch(x) catch (e) => "" + log := before + x + } + <button onClick={() => { add("a") }}>log: {log}</button> +} +``` + +`before` is `log` as it was when the add started. Written back after the +`await`, it erases whatever changed `log` in between: here the other add, +so the frames would end at `log: a`, not `log: ba`. The compiler sees the +read, the `await` and the write in one body, so this is an error; Solid +cannot see them and lets the lost update through. + +``` +-- reject: STALE_WRITE_BACK +export default component App() { + source items := [] + action add(x: string) { + let before = items + optimistic items := [...before, x + "…"] + let r = await fetch(x) catch (e) => "" + items := [...before, x] + } + <button onClick={() => { add("a") }}>{len(items)}</button> +} +``` + +The guess before the `await` is fine: it is this change's own picture. The +write after it is the lost update (found by the eval's round 4: two posts +at once, and a list that reloaded while a post was out). + +``` +-- reject: STALE_WRITE_BACK +export default component App() { + source detail := { id: 1, title: "a" } + action rename(t: string) { + let d = detail + let r = await fetch(t) catch (e) => "" + detail := { id: d.id, title: t } + } + <button onClick={() => { rename("b") }}>{detail.title}</button> +} +``` + +Through a field, too: `d.id` is `detail` as it was. If the user selected +another issue meanwhile, this writes the old issue over the new one. diff --git a/spec/04-rows.md b/spec/04-rows.md index b30927e..2f14657 100644 --- a/spec/04-rows.md +++ b/spec/04-rows.md @@ -78,11 +78,49 @@ and where they may not go. first answer lands: the equation is never pending on its first flight and `pending?` of it is `false` until then. Later questions follow R-4 and the hold rules. (Solid: `loadingValue`.) -- **R-8 (Imperative scope).** A pending value cannot be read as a snapshot; - in imperative scope, reading a pending equation is a type error unless - through `latest` or `peek`-with-a-default (05 decides the forms). A - handler that needs "the value when it lands" uses `await` in its own body, - which is ordinary JS asynchrony (SC-3: the continuation stays imperative). +- **R-8 (Imperative scope).** [taken by default 2026-10-30; replaces the + unenforced draft "reading a pending equation is a type error"] A snapshot + reads `σ` (H-10): an equation with a new question out reads its committed + value, which is the value the screen shows. What a snapshot cannot give is a + value that is not there: an equation whose row has `pending` with no + committed value yet (a first load without `-> v`, R-7), or whose row + `fails` (R-f-7: the snapshot throws). A handler (a plain arrow, an action, + an apply block) that snapshots such an equation `x` must either **handle** + it or be **covered**: + - handled: for the absent half, the equation has a first-paint default + (`await f() -> v`, R-7), so it always has a value (a probe does not + help: on a first load `pending? x` is `false` and `latest x` throws, + R-12); for the failure half, the read is inside a `try` or a `catch` + expression that takes its failures (`x.some(…) catch (e) => false`, + R-f-7). In an action, R-f-6 already requires the second; + - covered: the handler is on an element whose `disabled` reads `x` (the + usual form is `disabled={pending? x}`), or the element sits inside a + `loading` and an `errored` region whose content reads `x`. A covered + element is not there, or not enabled, while `x` has no value, a new + question out, or an error, so the handler never runs then: the element's + attributes are the handler's compute half, with the same separated + inputs an effect has (EF-1). + + Neither is `UNGUARDED_SNAPSHOT` (static). The check adds nothing at run + time and Solid has no equivalent: `untrack` of a pending node throws there + too, but nothing says so before it runs. A handler that needs "the value + when it lands" uses `await` in its own body, which is ordinary JS + asynchrony (SC-3: the continuation stays imperative). Found by the AI + eval's round 8 timing fuzzer (eval/results/fuzz-2026-10-30): in every + labels app, `add label` reads `shown.some(…)` from a button outside the + labels' `loading` region; clicking it before the labels' first answer + throws `snapshot(...).some is not a function`, and after a failed load it + throws the load's error out of the click (scenario S-guard-1 below). + + **S-guard-1** (handler over a first load). Program: `source labels = await + fetchLabels(id)`, `shown = labels.filter(…)`, a `loading`/`errored` region + listing `shown`, and outside it `<button onClick={() => { if + (!shown.some(l => l == input)) { addOne(input) } }}>`. Actions: mount; + click. Frames: `loading labels | [add]`, then a run-time `TypeError` (no + value). With `reject labels e` before the click: `labels error | [add]`, + then the click throws `e`. Both are `UNGUARDED_SNAPSHOT` at compile time. + Fixes: `disabled={pending? shown}` plus the read under `catch`, or the + button moved inside the region. Not yet checked by `rxc` or `ref/`. ## R-lane — display-ahead @@ -241,8 +279,8 @@ tracked. Inspired by solid-yield's typed failure color; the checks are | code | bin | rule | | -------------------------------- | --- | ----- | | `UNTRACKED_READ_AFTER_AWAIT` | U | R-5 | -| `PENDING_ASYNC_UNTRACKED_READ` | U | R-8 | -| `PENDING_ASYNC_FORBIDDEN_SCOPE` | U | R-8 | +| `PENDING_ASYNC_UNTRACKED_READ` | S | R-8: `UNGUARDED_SNAPSHOT` (new), a handler's snapshot of a value that may be absent or failing, neither handled nor covered | +| `PENDING_ASYNC_FORBIDDEN_SCOPE` | S | same | | `SYNC_NODE_RECEIVED_ASYNC` | U | R-1 | | `ASYNC_WATERFALL` | S | an `await` whose inputs depend on another `await` (R-2) | | `UNTRACKED_ASYNC_HANDLER` (structural half) | S | a handler that awaits with no write before and no `action` | diff --git a/spec/06-stores.md b/spec/06-stores.md index 820cfb4..cb68686 100644 --- a/spec/06-stores.md +++ b/spec/06-stores.md @@ -4,7 +4,7 @@ question, drafts, keys, streams, and the imperative forms that tie writes to async work: `action`, `optimistic`, `refresh`, `affects`. Rules cite Solid's F-9, F-11 and the derived rules they restate. In `ref/`: ST-1…ST-6, -AC-1…AC-5 (S-store-1…5, S-solid-8/9/15/20/26/32…35/37). Optimistic path writes: S-store-6. AC-6 `until`: S-action-2. AC-7 try/catch: S-action-1. AC-1b…AC-1d (inferred actions, held writes, stale own reads): S-infer-1…5. +AC-1…AC-5 (S-store-1…5, S-solid-8/9/15/20/26/32…35/37). Optimistic path writes: S-store-6. AC-6 `until`: S-action-2. AC-7 try/catch: S-action-1. AC-1b…AC-1d (inferred actions, held writes, stale own reads): S-infer-1…5. AC-1e (stale write-back): S-infer-6. --- @@ -194,6 +194,26 @@ selected = draft(pre selected -> {}) { d[id] := true; d[prevId] := false } the committed snapshot it always was, with no error. The fix is the local the value came from (`let n = seq + 1; seq := n; … n`) or a functional write (S-infer-5). +- **AC-1e (Stale write-back).** [taken by default 2026-10-28] In any + imperative run, a plain value write `x := e` after an `await`, where `e` + is computed from a read of `x` taken before that `await` — directly, or + through locals bound from it — is a compile error (`STALE_WRITE_BACK`). + The read is `x` as the run started; whatever changed `x` while the run + waited (another action, a reload, a click) is erased by the write: a + lost update. Keys count as in AC-1d: a read of `x.a` or `x` and a write + of `x.a` or `x` overlap; a read of `x.b` and a write of `x.a` do not; an + index computed from `x` (`let i = indexOf(x, …); … x[i].f := v`) is not + the written value and does not count. The fix is the functional write + (`x := v => …`, W-6), which applies to `x` as it stands at the write, or + a guess (`optimistic`, AC-3) that the answer replaces. A guess or a write + before the `await` is the change's own and is not checked; nor is an + awaited answer (`let r = await …`), which is the server's, not a + snapshot of `x`. The check is the language's (Solid cannot see the read, + the suspension and the write in one body): it adds nothing at run time. + Found by the AI eval's round 4 (eval/results/change-2026-10-28): 2 of + its 3 .rx defects, and a latent one in a run that passed every test; on + all 76 eval .rx apps and the repository's examples it fires on those 3 + runs only (S-infer-6). - **AC-2 (Not special).** An action is only a way to tie writes to async work; the hold is the thing (H-2). `refresh`, `affects` and probes behave the same inside and outside one. @@ -277,6 +297,7 @@ selected = draft(pre selected -> {}) { d[id] := true; d[prevId] := false } | `OPTIMISTIC_OUTSIDE_HOLD` (new) | S | AC-3: an apply block (never an action, AC-1b) | | `HELD_WRITE` (new) | S | AC-1c: an early write in an inferred action | | `STALE_OWN_READ` (new) | S | AC-1d: a read of the change's own write | +| `STALE_WRITE_BACK` (new) | S | AC-1e: a pre-`await` read written back after it | | `OPTIMISTIC_NO_QUESTION` (new) | S | AC-3 / ST-5c | ## Scenarios diff --git a/spec/07-surface.md b/spec/07-surface.md index 2471fe7..d893a5c 100644 --- a/spec/07-surface.md +++ b/spec/07-surface.md @@ -235,6 +235,8 @@ action { refresh user; affects user } // inline, one run (AC-1) `onFlush(() => { … })` if it is a reset that lands now, or declare the action to hold it on purpose. In every run, reading a source the run's own change wrote is `STALE_OWN_READ` (AC-1d): keep the value in a local. + A plain write after an `await` computed from the same source as read + before it is `STALE_WRITE_BACK` (AC-1e): write functionally, `x := v => …`. - `await` in an action body suspends the run and keeps its change open; in a plain run it ends the change (the continuation is a new one). A plain run that awaits writes nothing, or it would be an action. diff --git a/spec/99-diagnostics.md b/spec/99-diagnostics.md index 9088bc3..12ace81 100644 --- a/spec/99-diagnostics.md +++ b/spec/99-diagnostics.md @@ -25,8 +25,8 @@ Bins: | `ASYNC_STORE_SETTER` | U | ST: a draft block is synchronous by grammar | | `STRICT_READ_UNTRACKED` | U | E-4/E-5: the only snapshots are initializers and `peek` | | `UNTRACKED_READ_AFTER_AWAIT` | U | R-pending: inputs are static; no tracking window | -| `PENDING_ASYNC_UNTRACKED_READ` | U | R-pending: a pending value cannot be read in imperative scope without `peek`/`latest` | -| `PENDING_ASYNC_FORBIDDEN_SCOPE` | U | same | +| `PENDING_ASYNC_UNTRACKED_READ` | S | 04 R-8: `UNGUARDED_SNAPSHOT` (new): a handler's snapshot of a value with no committed value or a failure row, neither handled (`-> v`, `try`/`catch`) nor covered (`disabled` reading it, or inside its `loading` and `errored` regions) | +| `PENDING_ASYNC_FORBIDDEN_SCOPE` | S | same | | `PRIMITIVE_IN_FORBIDDEN_SCOPE` | U | SC-2: declarations do not occur in imperative scope | | `PRIMITIVE_IN_EFFECT_CALLBACK` | U | same | | `CLEANUP_IN_FORBIDDEN_SCOPE` | U | EF-5: cleanup is the apply block's return | @@ -102,6 +102,7 @@ named. | `OPTIMISTIC_OUTSIDE_HOLD` | S | AC-3 (an `effect`'s apply block: never an action, AC-1b) | S-check-1, S-infer-1 | | `HELD_WRITE` | S | AC-1c | S-infer-2 | | `STALE_OWN_READ` | S | AC-1d | S-infer-5 | +| `STALE_WRITE_BACK` | S | AC-1e | S-infer-6 | | `OPTIMISTIC_NO_QUESTION` | S | AC-3 / ST-5c | S-check-1, S-solid-32 | | `INVALID_REFRESH_TARGET`, `INVALID_AFFECTS_TARGET` | S | AC-4, AC-5 | S-check-1 | | `REGION_ESCAPE` | S | O-3, O-3a, O-3b | S-check-1 (seven shapes), S-module-1 |