Iris: "remove both decisions and iris.md. I've decided to instead make decisions when planning with agents rather than after they do things, and they're both too long for me to wanna read, + don't cover all the decisions I'll wanna make about the code anyways. I'll just naturally run into things for now. Todo is important though." So docs/DECISIONS.md (850 lines) and docs/IRIS.md (1,986) are gone, and AGENTS.md now says not to start another: raise a choice while planning it with her, otherwise decide it and put the reasoning at the code it governs. The TODO lists stay. docs/SUBAGENTS_DECISIONS.md went with them -- same artefact, same reasoning, and she did not name it, so its six decisions were folded into docs/SUBAGENTS.md rather than deleted. Deleting the logs left ~30 citations dangling in code comments and docs. Each states its reason inline and cited the file only for provenance, so they now read "decided 2026-09-07" or name the module doc that carries the reasoning. The root had six things that were not a program or a document. Moved, per "I only meant top level sh files": run-tests.sh, test-wg-tunnel.sh, wg-setup-host.sh -> scripts/ rigs/ -> scripts/rigs/ xtask/ -> scripts/xtask/ A project's own scripts stayed with the project: app/*.sh, app-rust/*.sh, iris/*.sh and server/enroll-link.sh did not move. `target/` at the root is deleted and cannot come back: there was never a workspace there, and the 29 MB was only xtask's scratch space, now in scripts/xtask/target/. `cargo xtask apk` still runs from the repo root and now publishes to scripts/build/outputs/apk/<mode>/ -- one directory deep, because that is what Dev Updater's `*/build/outputs/apk/*/*.apk` discovery pattern needs, and scripts/xtask/build would have been two. Verified: ./scripts/run-tests.sh and `cd iris && cargo test` green, clippy and fmt clean everywhere, `cargo xtask apk debug --abi x86_64` builds and signs an APK carrying lib/x86_64/libai_app.so at the new publish path, and the repo root is now eleven entries with no build output among them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
13 KiB
iris: known problems and things still to build
Iris's own list for the library, recorded 2026-09-04 in her words where it matters, so the work in RUST.md picks these up in a sensible order rather than rediscovering them. Each item says where it sits in the order and what "done" looks like.
Only open items live here. An item is deleted when it lands, not ticked: a list of finished work is context every future session pays for, and what a change did belongs at the code it changed. Fifty closed items and six phone-report sections went on 2026-09-08 for that reason.
Fix
- Where the scroll pin lives. The rest of "scrolling moves out
of the list" landed on 2026-09-08 --
ListisLazySpan, the physics and the gesture live in oneScrollController,.scrollable()is the only way anything scrolls, anddocs/SCROLL.mdis the standing reference; read that rather than reconstructing it here. What is left is one design question. The pin ("stay at the end as rows are appended") is still each widget's own:Scrollhassnap_endfor an ordinary child,LazySpanhas one for itself, and the constructor argument sets each. Iris asked foramtand "other controls (iirc only at end for now)" to live inScrollso a caller always edits theScroll; that is done foramtand not for the pin, because a pin has to be applied when a row is appended -- between frames, with no painter in hand -- so moving it needs either a fourthWidgetmethod or a parameter onapply_scroll. Nothing external edits a pin today (the transcript sets it once at construction and callsjump_to_endon the span for the rest), so this is a design question rather than a missing capability. - A read-only text display has no widget of its own — P0's bench
report area is a
TextEditstanding in for one (2026-09-05). The only way to get selectable text on screen today is.editable(...)plus.attr::<Selectable>(())(Selectableis only implemented forTextEdit,iris/src/attr.rs), which also makes the field focusable — tapping the bench report opens the soft keyboard over text nothing lets you type into. Harmless for a bench-only debug screen (not fixed this pass), but a real "selectable, not editable" text primitive would remove the keyboard side effect and is worth having before another screen wants the same thing (P1's own transcript rows already read their content from aTextEditfor the same reason).
Build
- Positions as a single float per scroll. Iris raised, and half rejected, letting a scroll update one float rather than positions: input handling cares about most elements in a list, so absolute positions must be computed on the CPU anyway. LAYOUT.md's design already lands here (GPU walks the chain, CPU resolves on demand for hit tests). Keep the CPU resolution lazy and per query; do not materialise every row's absolute position per frame.
- Animations, last. Cosmetic, so after everything above. Must be modular — a piece of the library rather than a core part forced into everything, the same way input is. Whatever the mechanism, a widget that does not animate must pay nothing and import nothing for it.
Found by P1a (2026-09-06)
- Desktop colours are washed out: the winit surface is sRGB and
the shader writes the palette's bytes as linear. Mocha Crust
(17,17,27) is drawn as (73,73,91), measured off
run-headless.sh --shot. Android is correct, so this is the surface format rather than the palette -- but it makes the desktop build useless as a colour reference, which is exactly what P1a needed it for when the emulator could not draw glyphs. - The bench report pane draws over the transcript rows instead of
replacing them. Visible on the emulator for the first time now that
glyphs render there (
/tmp/emu-final.png, 2026-09-06): after a bench run the report's lines and the transcript's occupy the same rows in the top third of the screen, both legible, neither on top. Pre-existing -- the same overlap is in a screenshot taken before the move-slot fix -- so it is its own item, most likely the report pane not masking or not claiming its region.
Found by P1b (2026-09-06), all with a headless repro
Each was found by looking at iris/run-headless.sh transcript -- -p transcript-ui rather than at a diff. docs/RUST.md's P1b box has the
fuller account.
No entry here is worked around any more (Iris, 2026-09-08: "All of those should be fixed. There should never be workaround code. Do the same for those; fix them if they're trivial, diagnose and report if not."). Two are fixed and ticked; the two that are left are missing capabilities rather than defects being dodged, and each carries its diagnosis and what building it actually costs.
-
No overflow ellipsis.
TextAttrscan wrap or not wrap; there is no "one line, ellipsised" the waymaxLines = 1+TextOverflow. Ellipsisgives Compose. A tool card's summary is clipped instead, so nothing on screen says it was cut. Whichever end is cut has to be a choice when this lands: a path is identified by its tail, a command by its head.Diagnosed 2026-09-08, and it is not trivial. parley has no ellipsis of its own (checked: nothing in the vendored crates), so iris would build it, and the shape that looks easy is the one that breaks something. The easy half really is easy: shape at
max_advance = width - ellipsis_advancewith wrapping on, take line 0'stext_range(), and re-shapetext[..end].trim_end() + "…"with wrapping off -- parley's own line breaker finds the cut, so nothing here counts glyph advances by hand. The hard half is thatTextBufferhas exactly one string and everything addresses it by byte offset: the inline spans that carry a fence's colours and a link's range,TextEditCtx::byte_at(which turns a tap into a byte to match a link against),Selection'sselect/selected_text, andRowBlocks::apply_delta. Truncating the buffer moves every one of those. So the real work is givingTextBuffera displayed string distinct from its source, with one mapping from display byte to source byte that all of those go through -- worth doing, and not a by-the-way. Doing it only for text that is neither editable nor selectable would avoid all of that and is exactly the kind of exemption that comes back later.It also wants an API change while it is open:
TextAttrs::wrap: boolcannot say three states. Something likeOverflow::{Wrap, Clip, Ellipsis(End)}replaces it, withEnd::{Head, Tail}making UI_RULES's "choose which end to truncate" a thing a caller must answer rather than a default nobody reads. -
A tool card's text is not selectable.
Selectionis keyed(RowKey, block index)and a card has no markdown blocks, so nothing in a card registers. Compose'sSelectionContainercovers tool output, which is the text people most want to copy.Diagnosed 2026-09-08: mechanical, but more than a sitting. There is no key collision to design around, which was the open question: a
TranscriptRow::Toolshas only cards and no markdown blocks at all, so a card is free to number its own texts from 0 in reading order. What it costs is the registration lifecycle rather than the key. Each card'sTextEdits have toSelection::registeras they are built andunregisterwhen they are not -- and a card is rebuilt from several directions (redraw_cardwhen a result arrives,Shared::set_contentwhen the group is toggled or a call joins the run, and the per-cardWidgetPtrswap), each of which frees widgets the map would otherwise still point at. That is the exact shape of the crashSelection::clear's doc records from review, 2026-09-06: a handle in that map outliving the widget panics on the next long press, somewhere else entirely. So the work is a per-card base index with a stride (and adebug_assertthat a card stays inside it), one register/unregister path that every rebuild route goes through, and a test per route that a rebuilt card leaves no stale handle behind.
Warnings standing in the bench build (2026-09-08)
Seen while checking cargo ndk -t arm64-v8a check --lib --no-default-features --features "transcript-screen bench" from
app-rust/, and left rather than silenced because it is a decision:
PlatformHandle::show_diagnostics_overlayhas no caller. It and the ~60 lines ofIrisView.showDiagnosticsOverlaybehind it are a plain-TextViewoverlay with Copy and Close, drawn over whatever iris is doing -- built so a report can be read even if iris itself has stopped drawing, which is the one case the in-iris diagnostics pane that replaced it cannot cover. So this is a live escape hatch nobody calls, not dead code: deleting both halves clears the warning and removes the fallback, and wiring it back to something is a product decision (Iris has nologcaton her phone). Ask before doing either.
Build (for the port)
Widgets RUST.md's "The port, in order (decided 2026-09-05)" needs and
iris does not have yet, one entry per gap, named against the P-step that
first needs it. Move an entry up to "Fix" or tick it in place once built;
do not duplicate it there.
- A history-paging cushion measured in on-screen viewports, not a
row count. (P1.)
iris::widget::Listhas no equivalent of the Compose app'sHISTORY_SCREENS— AGENTS.md's "Things that have bitten" is explicit that a fixed row count under-fills a screen on a tool-heavy transcript and over-fills one on a text-heavy one, so whatever loads the next page has to ask the list how many viewports are actually on screen, not assume a constant. - A scaled thumbnail/image widget for an in-transcript image.
(P1.)
SessionImage.kt's bitmap decode-and-downscale has no iris counterpart; iris's own image widget (used bybench_images.rs) draws a loaded texture but does nothing about sourcing or scaling one from a server-produced attachment. - A modal/dialog primitive. (P1, reused by P3 and
P5.) Needed for the session settings dialog,
UsageDialog's equivalent, and the delete-with-deleteForeignconfirmation with its toggle switch. Build once, wherever it is first needed, rather than once per screen that wants one. - A horizontal gauge/bar widget. (P1.) For
SessionUsageBar's equivalent — a bounded fill reflecting a fraction, nothing fancier. - A
BusyItemequivalent: a dimmed row carrying an operation label that does not block its list's own scroll/drag. (P3.) The Compose version tried an overlay first and it swallowed the drag along with the tap (AGENTS.md's "Shared appearance") — worth not repeating that attempt in iris before building the row-level version directly. - A toggle switch. (P3.) For the delete dialog's
deleteForeigncontrol; iris has no switch/checkbox widget yet as far as this pass found.
Reconsider
-
WidgetView. Iris is unsure of it: what she wants is an easy way to compose a widget from others (a button is the main case). With sizing folded intodraw, composing may be easy enough thatViewis redundant. Decide after the layout change lands, by writing a button both ways and keeping the one that is shorter to explain; delete the other rather than keeping two ways. -
A
Stackthat chooses its mask the way it chooses its size (Iris, 2026-09-08). She asked whethermasked_bydeserves to exist: "a method that just does 2 separate things you can already easily do does not deserve to exist." For a square-cornered surface it is indeed redundant --.background(rect(BAR_FILL)).masked()was measured against.masked_by(rect(BAR_FILL))on the composer at the phone's own size and density and the two are identical to the pixel. What the pair cannot express is a clip that is not a box:Painter::set_maskwrites aRectPrimitive::color(Color::NONE)at the widget's own region, with no radius, so.background(rect(fill).radius(r)).masked()draws a rounded panel and then cuts its content square. Both other call sites (row.rs's fence,tool.rs's raw output) are rounded, which is why the method stands for now. Her suggestion for removing it properly:Stackalready names where its size comes from (StackSize::Child(n)); let it name where its mask comes from the same way. Then.background(x)is the one way to put a surface behind something, and clipping to that surface is a property of the stack rather than a second wrapper --masked_bygoes, andMasked::shapewith it. Worth checking while designing it: what a stack with no mask child means (today's behaviour), whether the mask child must also have been drawn first (set_mask_to_widgetrequires it, andStackdraws in order, so naming child 0 is safe and naming a later one is not), and what happens when the named child is the same one the size comes from.