Commit Graph
4 Commits
Author SHA1 Message Date
irisandClaude Opus 5 be47beb0ff Say it over the app when the app is what somebody is looking at
The notification stream now has three places to land instead of two, decided
in one function. Nothing at all for the session on screen, as before. A
banner over the app while the app is up. Android's drawer otherwise. Never
two of them for one moment: a drawer filling up behind an app that showed
you each one is a drawer nobody reads.

The banners queue, one per session replacing that session's own -- the rule
the drawer already followed, and for the same reason. Each can be tapped,
which opens the session by the same path a tapped notification takes;
pushed off either side; or left alone, in which case the bar across its foot
retires it. The bar and the retiring are one value rather than a bar beside
a timer, so a banner cannot outlive the countdown drawn under it. They clear
when the app goes away, since a claim that a session wants somebody *now*
does not survive an absence -- and the drawer has the job back by then.

Which of the three applies needs no flag anybody keeps level. The session on
screen is registered by the one composable that draws one, and "the app is
up" is the queue being collected, which happens exactly while it is.

Also: tapping a model or permission button while its own menu is open now
closes it. A non-focusable popup does not swallow the press that dismisses
it, so the same finger was reopening what it had just closed -- measured at
3ms between the two, which is what the guard is sized against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 02:40:32 -04:00
irisandClaude Opus 5 9d5a7cf602 Leave the session you are reading alone, and put its menus on their buttons
Three things about the session screen.

A notification is no longer posted about the session in front of you: the
transcript is already saying it, and one that was posted before you opened
it is cancelled, since a row in the drawer for the conversation on screen
is the same duplication. Bound to RESUMED rather than STARTED, so a session
left on this screen behind another app still reports.

The model and permission menus opened 142px clear of the buttons that
opened them -- the status bar's height, exactly. Compose measures the
anchor in window coordinates, which for an edge-to-edge activity is the
whole display, but asks whether the menu fits inside the visible frame,
which is that less the system bars; sitting just above a control near the
bottom then reads as an overflow and Material3 parks the menu near the
bottom of the visible frame instead. Turning clipping off makes both
questions about the same window.

The model picker now offers "default". The button has always been able to
say it -- that is what a session with no model of its own reads as -- but
the list could not, so choosing any model was a one-way trip. It is the
Claude CLI's own word for "whatever is configured", which its set_model
accepts, so it is a request rather than a name invented here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 02:10:39 -04:00
irisandClaude Opus 5 ea2da0896d Open the session a notification is about, and say the two dividers plainly
Tapping a notification landed on whatever the app was last showing. It now
opens the session it named. The id rides in the intent's data rather than an
extra, because PendingIntent identity is Intent.filterEquals -- with an extra
every session's notification would share one PendingIntent and every tap would
open whichever session was notified last. MainActivity sorts the aiapp:// URI
by host, so enrollment and this are one entry point rather than two.

The notification carries only an id, so the session is fetched before there is
a screen; a fetch that fails says so and offers to try again, since somebody
deliberately tapped and an app that opens to the list explains nothing.

That made session-to-session navigation reachable for the first time, and it
crashed: SessionScreen remembers a transcript and an event stream, and without
a key Compose kept both across the change and merged two conversations into
duplicate list keys. Keyed on the session id.

The two transcript dividers now say only what they are, centred between two
rules: "Compacted <bullet> 128,402 -> 9,617 tok" in blue, and "Context cleared"
in red. The rules stay the ordinary divider colour -- they are framing, and the
words are what carries the meaning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 01:23:37 -04:00
iris 135950c8ed Say when a session wants you, and stop calling a stop an error
Three things Bryan asked for, and one the second of them exposed.

**Notifications.** A session that asks a question or finishes a turn now
says so on the phone, per session, switchable from its settings screen.

The switch is stored on the backend rather than the phone, because it is a
fact about the session: one that runs unattended overnight should be quiet
on every device, and answering that question again on each device is how two
of them come to disagree. It is on by default -- a notification nobody
wanted is turned off in one tap, where one that never arrived is not
diagnosable at all.

Which moments count is `notification_for`, and the asymmetry in it is the
point. *Waiting on a person* is worth saying however it was reached.
*Finished* is only worth saying when this server watched the work happen:
sessions settle into idle for several reasons that are not "your work
ended", including every one of them being adopted at startup, and announcing
those would put "finished" on the phone for the whole config on every
backend restart. That is the failure that makes somebody switch the feature
off, so it has a test naming every transition rather than the two that
work.

The stream is `GET /notifications`, live only and with no cursor -- the one
place this server does not offer to catch a client up. A notification is a
claim about now; replaying "your turn" from an hour ago sends somebody to a
session that may have been answered from another device since, and a
notification that is wrong costs the trip *and* the credibility of the next
one. What was missed is still on the session list, which says what is
waiting without claiming to be news.

On the phone it is a foreground service, because Android has had no
long-lived background service since 8.0 -- it is what Syncthing does, and
Discord is not a counter-example since it takes a push from Google, which
would mean this backend talking to Google about somebody's sessions. The
ongoing notification Android charges for it sits on an `IMPORTANCE_MIN`
channel: no sound, no status-bar icon, bottom of the shade. `specialUse`
rather than `dataSync`, which is what it looks like: Android 15 caps
dataSync at six hours a day, and a connection that stops listening after six
hours misses the overnight run it exists for.

**A stop is not an error.** The CLI reports an interrupted turn exactly as
it reports a broken one -- `is_error` on a `result` -- so pressing Stop
showed "the turn ended with an error" for doing what the button says. The
line cannot distinguish them; what does is that this side asked, so the
driver says so before the request goes out and the translator spends that on
the next result. The test's second half is the one that matters: the naive
fix passes the first half and silences every genuine failure after it.

**Every status says which one it is.** The session screen's status row named
only `exited` and left the rest blank, so idle and "nobody could read it"
looked identical -- and a just-stopped turn showed nothing, which reads as
the app having lost the session rather than as the stop having worked. The
words are the session list's own, so a state is not called two things
depending which screen you are on. Red on a quota bar now starts at 90%.

**`GET /sessions/{id}`**, which the notification switch found missing. A
screen opened from a list row carries the row the list last fetched: fine
for a title, wrong for a switch, which is *set to* something. Caught on the
emulator, where the switch read on against a backend that said off, with
nothing on screen to say which was true. The screen now reads the session
when it opens, and until that answers the switch is disabled and says so --
a two-position control cannot say "I do not know", so it does not pretend
to.

Verified on the emulator with the app backgrounded: the service holds the
stream (`isForeground=true types=0x40000000`), a finished turn posts
"Finished" and a question replaces it with "Waiting for you" on the same
tag, turning the switch off silences it with no restart, and turning it back
on from the phone reaches config.ron. The interrupt is a translator test
rather than a live turn, which is where that logic is anyway.
2026-08-29 23:43:19 -04:00