Skip to content

Nightly review - #6

Open
HEGADE wants to merge 28 commits into
mainfrom
nightly
Open

Nightly review#6
HEGADE wants to merge 28 commits into
mainfrom
nightly

Conversation

@HEGADE

@HEGADE HEGADE commented Aug 13, 2026

Copy link
Copy Markdown
Owner

No description provided.

HEGADE added 28 commits August 11, 2026 22:06
Two faults from one report: a shared session showed a broken frame where every
image should be, and the link worked over Tailscale but not off it.

The frame was correct rather than faulty. GET /api/sessions/{id}/file is
owner-only and deliberately absent from the share gate, because a share link is
a public URL and that route reads files inside the session's folders. Before
images existed nothing shared needed a file at all, so the gap only appeared once
the agent could draw. A guest now gets a different route with a different root:
only the generated-images directory, which holds nothing but what the model drew
in the conversation they are already watching, so it leaks nothing they cannot
already read. The path they send is reduced to its base name and joined to that
one directory, which makes traversal inexpressible rather than refused. The
owner's route is unchanged and its share-gate test still pins it.

The link was the Funnel. Its mapping lives in tailscaled and points at a NUMBER,
so a restart that lands the gate on a different loopback port leaves it
resolving to nothing. The tailnet path does not go through the public ingress,
so it keeps working, and from the owner's own machine nothing looks wrong at all.
kunai self-updates and is restarted unattended, which is exactly when nobody is
watching a link they handed out. funnelStatus already recognised the stale
mapping and offered the port back; only a human clicking "make public" ever acted
on it. reopenPublicPortIfStale now re-aims it on the reconcile tick, and only
ever a port already served and pointing at a dead loopback address, so somebody
else's Funnel is left alone exactly as the close path leaves it.

Proven live: a real Codex session generated an image, was shared, and the guest
page rendered it at 1254x1254 with no broken frame. On the same running gate, an
image inside the session's own folder, /etc, a traversal, and a bad token were
all refused, and the owner-only file route still 404s there.
Sending from a shared session left nothing between the message and the first
token. The state was arriving and the header dot and Stop button both changed,
but neither is where somebody is looking after they press Send, so a turn that
thinks for half a minute before writing anything was indistinguishable from one
that was never delivered. A guest has no other way to check: no sidebar, no
session list, nothing but the page in front of them.

It is the same line the owner's chat has always shown, on the same condition,
in the place the reply will appear.

Measured on a real shared session, paired and approved: the line appears 0.4s
after Send and is replaced by the reply.
The live activity opened collapsed, so every query began with the same click.
While an agent is working, what it is doing IS the content: you are watching it,
and a disclosure whose answer is always "yes, open it" is not a choice. It now
starts open and the finished turn collapses on its own, which it already did,
because a settled turn renders the ToolGroup summary instead.

Reopening is keyed on the number of turns rather than on whether one is running.
A turn's blocks change constantly while it works, and only a NEW query should
overrule the reader: closing it mid-turn is respected for that turn and
forgotten by the next.

Measured over four queries: open while running each time, closed once finished
every time, a mid-turn collapse honoured for that turn, and the query after it
open again.
Opening the live activity by default reintroduced the exact failure the
component was built to prevent, and added one of its own.

It rendered every block in the turn. Chat.svelte already renders the reply's
prose below it, so opening it printed the answer a second time; and interleaving
the two meant a paragraph, a command, a paragraph, a command, each arrival
shoving the rest of the conversation further down. Collapsed, neither showed,
which is why both survived until the disclosure started open.

Now it renders the tool calls only, in one block above the reply, and that block
is capped and scrolls itself. The prose reads as one continuous column with the
activity beside it rather than as two things taking turns, and a turn that makes
forty calls no longer grows the page by forty rows. The scroll pins to the newest
call only when the reader is already at the bottom, so scrolling up to read an
earlier command is not undone by the next one arriving.

Measured on a five-call turn: three calls in the box at 81px, zero prose blocks
inside it, the reply intact and unduplicated below, and the whole thing collapsed
to "5 tool calls, 5 messages" when the turn ended.
Sharing already lets somebody work in a session, and a screenshot is most of what
people send when describing a problem, so this was the obvious next thing and it
was refused outright. The refusal was right at the time: no upload route existed
on that listener, so an id arriving from one could only be somebody probing the
reader that resolves it.

Three rules replace it, each closing a hole the others do not.

Images only. A non-image upload is copied into the session's working directory so
the agent can read it, which for a guest is writing a file into somebody else's
repository. An image is inlined to the model as base64 and never touches the
project, so the safe subset is exactly the one offered.

Only the paired guest. Holding the link is enough to watch; sending takes the
pairing the owner approved, and uploading is sending.

And only ids this link was issued. This is the load-bearing one: the uploads
directory holds the OWNER's files too, and an id is all that names one, so an
unchecked id is a way to have somebody else's screenshot inlined into the
conversation and read back. The gate remembers what it issued to whom and accepts
nothing else, with one answer for every bad id so the shape of the refusal says
nothing about what exists.

Two smaller things fell out. redactEvent still strips the owner's attachment
names from a strict share but keeps a guest's own, or the message they just sent
comes back without the picture that was the point of it. And the client reads the
server's reason out of its JSON envelope instead of showing the reader
{"error":"..."}.

Proven end to end: no attach button before pairing, one after; a .txt refused
with "only images can be sent through a shared link" and nothing stored; a real
PNG staged, sent, and answered "Red" by the model; and the chip for it on the
guest's own message.
"Reopen it" answered "cwd required", which is the server describing its own API
rather than anything a person can act on.

The connection learns its folder from the hello frame, and a session that no
longer exists never sends one. So on a page loaded AFTER the restart -- which is
the ordinary case, since kunai self-updates and you come back to a tab pointing
at a session that ended while you were away -- conn.cwd was empty and the create
was correctly refused.

Recent knows: it reads the folder out of the transcript, which is the same file
the resume replays. The account and the title come from there too. Recent only
holds the newest handful, so a session older than that is asked for directly
rather than given up on: reopening an old session is exactly when somebody has
been away long enough to need it. And when nothing knows the folder, it says
that instead of passing the server's sentence through.

Reproduced and fixed against a real restart: a session with a turn in it, kunai
stopped and started, a fresh page load straight at the dead session, Reopen ->
conversation restored and the composer usable, with no error.
I put it above the reply, which was the wrong reading of what was asked for.

The order is the point. What the agent has SAID so far is the thing being read,
so it stays where reading starts and grows downward as prose does; the activity
belongs at the bottom edge, next to Working..., which is where the eye already is
and where the newest call appears. Above the reply it is chrome in front of the
content. Interleaved, which is what this did originally, a paragraph and a
command take turns shoving the page down.

So: reply, then one bounded panel of tool calls, then Working... And it still
collapses up into the summary line when the turn ends, which was always the other
half of the request.

Measured on a four-call turn: every prose block above the panel, the panel above
the Working line, and the whole thing collapsed to a summary at the top when it
finished.
…dy did

Opening the activity by default showed every call at once, so the answer to
"what is it doing now" sat at the bottom of a growing column of what it had
already done. That is clutter dressed as information, and it undid the one thing
this component was built for.

Collapsed, the head IS the answer: the current tool, and now the command it is
running. "Bash" on its own says a command is going without saying which, and for
a shell call the command is the entire answer, so the head carries it -- one
line, clipped, with the agent's `cd … &&` preamble dropped the way ToolCard
already drops it. Beside it, a count of what came before.

Everything else is the record of how the answer was reached, and it belongs
behind a click: this disclosure mid-turn, or the ToolGroup summary afterwards.

Measured on a four-call turn: zero call rows visible while running, the head
stepping "Bash sleep 3; echo alpha" then "Thinking 1 done" then "Bash sleep 3;
echo bravo 1 done" and so on, then "4 tool calls, 1 message" collapsed at the
end, opening to all four on a click.
The head showed the current call only while it was UNANSWERED, and a fast tool
answers in a blink: a Read returns immediately, so the line said "Thinking" for
the whole turn and the file it had read could only be seen by expanding. The one
line that exists to say what the agent is working on was saying nothing for
exactly the most common kind of call.

It now names the most recent call either way. A settled one recedes a step and
takes a tick, so the row reads as "it did this and is thinking about it" rather
than as a call still in flight, and "Thinking" is left for the only case that
honestly is it: before there is any call to name.

Measured on a two-Read turn: the head stepped "Read alpha.ts 1 done" then
"Read beta.go 2 done", where before it showed neither.
The head rendered the filename as plain text while the tool card beneath it used
FileChip, badge and all. So the one line you actually watch was the only place a
file did not carry its mark, and expanding a call showed something that looked
like a different thing rather than the same call twice.

It is the same component now, so a .ts is badged in the summary exactly as it is
in the card, and the dead .file rule it used to need is gone.
The card offered ":37595 server.test" as something to share. That is a Go test
binary: `go test ./...` compiles each package to <pkg>.test and runs it, and a
suite that stands up an httptest server binds a real port for the couple of
seconds it takes. kunai's scan caught one mid-run and listed it, so the offer was
a link to a process that had already exited by the time anyone could tap it.

Matched on the `.test` suffix, which is the toolchain's own naming rather than a
guess: a Go test binary is always named that way and nothing anybody runs on
purpose is. Kept separate from isOurs because it is a different claim -- that one
says "this is kunai", this one says "this is nobody's dev server".
The Providers dialog told you kunai runs a local CLIProxyAPI for you. That was
true when providers shipped, and it stopped being true for the two anybody picks:
Codex and Grok go through kunai's own in-process proxy, on by default, and the
sidecar is never downloaded when every configured provider is native.

Naming it was doubly wrong, because the honest answer is no longer one thing.
Kimi is not built natively and still needs the sidecar, and so does Codex or Grok
if its login is missing. A lede cannot say all that, and should not have to: which
proxy carries the request is kunai's business, not something to sign up to.

So the copy says what is actually being offered -- sign in once, the agent is
unchanged, only the model differs -- and the comments beside it now carry the
real, conditional answer for whoever reads the code next.

The MIT attribution for the ported translator is untouched: it lives in the Go
files that carry the port, which is where a licence notice belongs.
There was no scrolling logic in the guest page at all. A shared link opened at
the top of the transcript, so somebody who tapped it to see what was happening
landed on the first message and had to scroll a whole conversation to find the
live end; a streaming reply did the same in reverse, growing below the fold while
they watched a stationary screen.

Two attempts got this wrong the same way before the third worked, and the reason
is the useful part: scroll POSITION cannot tell you whether the reader moved. The
column grows under them constantly -- an image finishing its download long after
the markup that holds it, a reply streaming -- so every position-based heuristic
read "the content got taller" as "they scrolled up", and the page unpinned itself
the instant after it pinned. Twice it shipped a link that opened halfway up its
own transcript with a Latest button already showing.

So intent comes only from things that unambiguously are intent (wheel, touchmove,
keydown), growth is handled by a ResizeObserver on the column, and each jump we
make spends the scrollend it causes so the browser's own event cannot be mistaken
for a person. Measured four loads in a row landing at the bottom, where the
previous version managed one in three.

The Latest pill anchors to the footer's top edge rather than a fixed offset: that
footer is a composer for one guest, a join box for another and one line for a
third, and a guessed offset put the pill on top of whichever was tallest.

And the phone rules, which had to move to the end of the sheet to beat the
same-specificity rules they override: pictures cap at 300px rather than 460 (over
half an 844px screen), send controls take a 40px tap target, the composer bar
wraps so the owner's-approval caveat keeps its own line instead of being squeezed
to nothing, and the join box stops taking a sixth of every screen from somebody
who may only ever want to watch.
The engine was one shot: a single prompt that asked for everything and
ended by telling the model to delete the findings it could not
demonstrate. That is the author of a claim marking its own homework, and
it does not catch the thing it exists to catch. The prompt's own header
names the failure mode: the characteristic failure of machine review is
confident nonsense. Asking harder does not fix it; a separate pass with a
fresh context does.

So a review now runs in phases. Survey reads the change and says what it
is for and where the risk is. Find hunts, and is told to be generous
rather than to self-censor, because recall is the half that cannot be
recovered later. Verify hands every claim to a Task subagent and asks it
to REFUTE the thing, defaulting to refuted when it cannot demonstrate the
claim. Rank dedupes and sorts.

Both skips answer the obvious objection, which is cost: a small change
goes straight to Find, and a Find whose every candidate was already
demonstrated has nothing for Verify to do.

Allowing Task widens nothing, and that was measured rather than assumed.
Against a real CLI with --disallowedTools "Bash,Write,Edit", a subagent
reported its own toolset as Agent, Glob, Grep, Read, Skill, ToolSearch,
and the run recorded no permission denials.

Findings carry severity and confidence, which are two fields because they
answer different questions: how bad if true, and how likely to be true. A
single score can only lie about one of them. That is what gives the
review a shape, and the shape is the whole fix for the surface: twelve
identical cards in emission order had nothing to say which one was the
data-loss bug. Now the worst thing is at the top, the headline reads "2
blockers, 5 minor" before anything else, and a verdict may lower a
severity but never raise it, so verification restrains claims instead of
giving each one a second chance to inflate.

What verification refuted is kept and shown, collapsed, with its reasons.
Three findings from a reviewer that dropped four is a different thing
from three findings from one that only found three, and nothing else can
tell them apart.

A finding is editable before it is posted, so wording that is wrong no
longer costs the finding it was attached to. Only the words: the anchor
decides which line of somebody's pull request a comment lands on, and it
stays server-side.

ReviewDraft.svelte is gone. Two surfaces for one thing is how they drift,
and these had.

Two index-mapping hazards are pinned by tests because both fail silently
and both post the wrong thing: a verdict echoes its claim's path, so one
that has drifted onto the wrong finding is discarded rather than
refuting an unrelated bug, and edits apply before the keep-filter, since
filtering first shifts every edit onto its neighbour.

A screenshot caught what every selector assertion walked past: overruling
the only blocker down to a minor left the headline still announcing a
blocker, because the tally counted the original severity. The summary now
counts what will actually be posted.
Saving credentials checked only that the PEM parsed. That check passes
for a key belonging to a different App, for the right key with the wrong
id, and for an App installed on nothing at all, so all three sat on disk
reporting "Configured" in green and failed later as a raw GitHub error
string on the dashboard. Setting this up is already the most tedious part
of the feature; having it silently not work is what makes it feel broken
rather than merely fiddly.

Two questions are now asked of GitHub before anything is written. Whoami
proves the id and the key belong together, which GitHub will not answer
otherwise. Installations answers the step everybody misses, because
registering an App and installing it are separate actions and only the
first one feels like setup.

They fail in three ways that need three different sentences, so the
result is not a boolean. A pair GitHub refuses is refused here, while the
two fields somebody just pasted are still on screen. An App installed
nowhere saves, and says so with a link to install it. GitHub being
unreachable also saves, and says that instead, because refusing there
would send somebody to re-paste a key that was always correct.

A partial installation is called out too. "Selected repositories" is the
setting behind the confusing failure: everything reports configured and
one repository still refuses because it was never ticked.

The pull request list hides drafts by default, which is not a preference:
a draft is explicitly unfinished, so reviewing one spends real quota on
code its author has not asked anybody to look at. They are one click
away, and named when shown. Filtering matches the number as well as the
words, since "the 4300 one" is how people refer to a pull request, and
the box only appears once the list is long enough to need it. An empty
result says whether nothing matched or everything open is a draft, which
a blank card cannot.
Settings, Accounts, Providers and Channels were modals. They are routes
now, for the reason Usage was made one first: a modal is for a decision
you are making on top of what you were doing, and it takes the screen
hostage to say so. None of these four is that. Being a route buys the
back button, a reload that lands where you were, a link you can send, and
the full width instead of a 720px sheet with the app greyed out behind
it.

The clutter in Settings was structural rather than visual. One scrolling
column tangled two different scopes: notifications belong to the BROWSER
you are holding, and everything under them belonged to ONE MACHINE,
chosen by a chip picker halfway down, after which the rest of the page
silently re-scoped. Nothing on screen said which switches followed that
picker, so the answer to "is this setting global?" was to read the
source.

So scope is the organising principle. The rail's group headings say whose
settings the links under them change, and the third heading is the
machine's own name rather than the word "Machine", which would be one
more thing that does not say which one. Picking a different machine
renames it and the four sections under it are exactly the ones that
follow. On a phone the rail becomes a strip of chips, where those
headings cannot fit, so the page subtitle carries the scope instead.

Discover and Add a machine moved out of the body. Finding a machine is
fleet management rather than a setting, so they are actions on the
Machines section and absent everywhere else, where they would read as
global.

Five open methods that each had to remember to close the other four are
now one openView. That was a rule enforced by copy-paste: "exactly one of
these is showing" is an invariant and five independent booleans cannot
hold it. VIEW_PATHS names each route once so the writer and the reader
cannot drift into disagreeing about a string, which is the classic way a
route half-works, navigating while the back button lands nowhere.

Page.svelte holds the header all five share. Usage had it hand-written
and the four modals each had their own; five copies of one header is how
they drift. The back button is load-bearing rather than decoration: a
phone hides the sidebar while a place is open, so without it there is no
way out except the browser's own back, which a home-screen PWA does not
show.
… twice

The last pass moved the modal's contents into a page and called it a
redesign. It was not one. Accounts came out of it existing in two places:
a page with the real sign-in flow, and a section inside Settings listing
the same accounts next to a link across to that page. Two surfaces for
one idea is not navigation, it is a question the reader has to answer
before they can do anything.

Settings is now the one place configuration lives. Accounts, Providers
and Channels are sections of it, reachable at /settings/<section>, so the
sidebar shortcuts still go straight to them: one surface, several doors.
Each shortcut marks itself current when its section is open, since a nav
item that opens a page and then claims nothing is open is how the same
page gets built twice in the first place.

The Accounts section is the real thing, with its sign-in flow. What it
replaces is what the copy left behind: a raw roster of names and config
folders, and a form that asked you to type a path to a directory you had
to have already logged into from a terminal.

The page has a shape now instead of controls stranded in a corner of an
empty canvas. Every section opens by saying what it is and what it
decides, because a settings page that lists only nouns makes you open all
of them to find the switch you came for. Below that, the card is the unit:
rows divided by hairlines inside one bordered group, rather than lines
ruled across the full width of the window with the control a hand-span
from its label. The rail and the panel are centred as one block, so the
content is not pinned to an edge with a lake of empty beside it.

The components stopped repeating the page. Network carried a "NETWORK
ACCESS" heading, Reviews carried "GITHUB", and Providers and Channels
each opened with a paragraph re-explaining their own title; all four were
written when they were blocks in one long column and the section header
now says it. What is left of each lede is the part the header cannot
carry: that a provider changes the model and nothing else, and that a
channel carries the conversation and not your files.

Machines keeps Find and Add, under the list they change rather than among
the switches, and the CSS orphaned by all of this is gone rather than
left behind.
Each section styled itself, and it showed. Accounts was cards; Reviews
was a wall of bare paragraphs and unlabelled inputs. One "Add" affordance
was a dashed outline while every other control on the page was solid.
Each component set its own row height and padding, so a one-word row like
"Claude" came out 110px tall with its badge a hand-span away from it. And
Reviews' status line, an unwrapped flex row carrying an App name, a list
of organisations and a numeric id, pushed the page wider than the window
and cut its own prose off at the right edge.

Scoped styles are what allowed that: six files each describing a row,
none of them the same row. So settings.css defines one card, one row, one
button and one field, globally, and every section uses them. A new
section gets the look for free and changing the look is one edit rather
than six. The metrics are tighter than what they replace, because
settings is read by scanning rather than by dwelling.

Reviews is rebuilt on it: the App's state is a row with a dot like every
other status in this app, the id and key are labelled fields inside a
card, and the instructions are a note under it rather than four lines of
prose inside a row with a status dot floating in the middle of them.
Replacing a working key now stays shut until asked for, instead of
sitting open under a working App and making the section look like setup
you had never finished.

The accounts list and the way to add one are one card rather than two
containers with two different borders, which had made a list of two
things look like two unrelated widgets. Providers follows, because it is
the same kind of list. Network moves over too, keeping the one rule that
does not come from the shared file: a divider has to follow a feedback
line as well as a row, or it vanishes exactly when a message is showing.

The smoke test now asserts both failures directly: every section renders
the shared card, and no section overflows the window.
Eight text labels stacked in a column is a list, not a navigation. There
was nothing to tell one section from another without reading it, nothing
for the eye to return to, and no left edge for the labels to align
against.

Each section now carries a glyph, drawn in the same hand as the app's own
nav icons: 24 viewBox, 1.7 stroke, round caps. The icon sits dimmer than
its label at rest and comes up with it on hover, so a row reads as one
thing rather than as a picture next to a word, and brightens fully on the
open section.

The machine's name was the worst of it. As a dot and a word at link
height it read as a nav item that happened to be disabled, when it is the
heading that says which machine everything below it belongs to. It sits
on the group-label line now, above a hairline: a heading has to sit on
something.

And on a phone the strip could open scrolled past its own selection.
Arriving at /settings/unattended showed a strip starting at Notifications
with nothing marked, which reads as no section being open at all. The
open chip now scrolls itself into the middle, sideways only and only when
the strip actually scrolls, so the desktop rail never moves.
Every group was a bordered, filled card. Inside it every button was a
filled grey rectangle, and inside those a field was another filled grey
rectangle. Boxes inside boxes inside boxes, all the same grey, so none of
them meant anything. A paragraph about which account reviews run on was
wrapped in a card for no reason other than that a card was what the
stylesheet had to offer.

Grouping is done with space and a hairline BETWEEN rows now, never with a
box around them. No fill and no outline on a group, and rows sit flush to
the section's left edge, so the page has one strong vertical line down the
left instead of three nested ones. Negative margin against matching
padding keeps that alignment while still leaving a hover fill room to
breathe rather than hugging the letters.

Fill is spent on two things, because spending it on everything is what
made the page grey: the ONE primary action of a section, and a hover.
Every other control is text, coloured rather than boxed. Remove, Change
and Replace were grey rectangles competing with the sentences beside
them; as quiet text they are no harder to press and no longer shout.

A field keeps its outline, since that is the one place a border does real
work: it says you may type here. The fill came off, so it reads as an
opening in the page rather than another grey block sitting on it.

The thermal limits hang off a single vertical hairline instead of sitting
in a tinted well, which says "these belong to the switch above" with one
line. And a machine is only marked as chosen when there is something to
choose between: on a one-machine fleet the highlight was a band of fill
answering a question nobody had asked.
appearing when it has nothing to say

Two faults, and they made each other visible.

The effect that loads pull requests read `repos` inside itself, which
makes the ARRAY its dependency. The app store hands out a fresh array on
every poll even when the fleet has not moved, so the effect re-ran every
few seconds: GitHub calls on a beat nobody asked for, against a rate
limit, for an answer that had not changed. Measured on the previous
build, 4 refetches over 25 idle seconds; on this one, 0. It keys on a
STRING of the same information now, derived outside the effect, and calls
load inside untrack. The Usage page had this exact bug and this exact fix.

The second fault is what made it visible on a machine with no open pull
requests. The card rendered while LOADING, so every one of those refetches
opened a heading reading "PULL REQUESTS reading…" and then closed it
again, shoving the dashboard down and back on a loop. Loading is no longer
a reason to render: the fetch happens quietly and the card exists only
once it has rows to put in. Rows are also no longer cleared before a
fetch, so a reload cannot make an existing card blink out and back.

Separately, three NUL bytes were sitting in this file's source, committed,
where spaces belong -- including `join('\0')`. Harmless in behaviour and a
landmine to edit around, since the text does not match what it looks like.
Replaced with the spaces they should have been.
The Reviews section flipped from "Working" back to "Checking with
GitHub…" every few seconds, for ever. The status was honest each time:
the effect that reads it really was re-running, and the check takes the
second or two that two round trips to github.com cost.

Measured before guessing. The effect ran four times in sixteen seconds
with `machineId` and `base` identical every time, on ONE component
instance, since onMount fired once and ruled out a remount.

What re-triggered it was reading the PROP. Settings passes
`machineId={selM.id}`, selM is derived from app.machines, and the store
replaces that array on its poll beat, so the prop is invalidated even
though the id is the same string. The neighbouring sections were immune
only by accident: their effects happen to read `base` and never the prop.
Measured at 1 fetch on load and 0 while idle, against this one's 3 and 2.
Channels does poll on its own 10s timer, which is deliberate and stays.

So the effect now remembers what it last loaded and returns when nothing
has changed. That holds whatever the cause of the invalidation, which an
untrack around the body would not: the effect still runs, it just finds
nothing to do. After: 1 fetch on load, 0 over 25 idle seconds.

Also removes web/shot.mjs, a scratch measurement script that went into
the previous commit by accident.
Opening Accounts listed every account, then dropped back to one or two a
moment later. The shrink was the visible half; the wrong half was what it
listed in between.

The list paints instantly from the machine's cached names so it does not
open on a spinner, and it took those from stats.clis. But stats.clis is
what a New Session picker offers, which the server builds as accounts AND
providers (cliNames appends providerList), while /api/accounts is
accounts alone. So the seed listed Codex and Grok as Claude subscriptions
for the moment before the fetch landed, and then removed them.

Reproduced against a scratch machine carrying two accounts and one
provider: stats.clis came back ["Claude","Work","Codex"] and
/api/accounts came back Claude and Work. The seed now filters out the
keys of provider_models, which is exactly the set of provider names, and
sampled forty times through the load, Codex never appears and the panel
holds one height.

Two other readers of stats.clis are left alone deliberately, because for
them the combined list is the right one: the auto-failover row asks
whether there is more than one thing to move BETWEEN, and it can move to
a provider; and the account a review runs on is offered as "a second
account or a provider" on purpose.
…g back

The folder list grew with every codebase ever opened, until the nav at the
bottom was pushed off the screen. Folders holding nothing but past work
are capped at three now.

Two things are never counted against the cap and never dropped. A folder
with something LIVE in it stays wherever it sits, because the sidebar's
job is to show what is happening, and hiding a running agent to make room
for a folder somebody last opened on Tuesday is the wrong way round. And
pinned work is unaffected by construction: a pin already lifts a session
out of the groups into its own flat section above them.

The rule lives in grouping.ts as a pure function rather than inline in the
component, because "a live folder is never hidden" is the half that would
regress silently. Six cases pin it: the cap itself, the count of what was
cut, a live folder sitting below the cut, live folders not spending the
quiet budget, a folder holding one live session among past ones, and a
short list hiding nothing.

"View all sessions" names the remainder rather than letting a folder go
missing without explanation, since everything cut is one tap away there
and that view is searchable and paginated.

Unrelated and NOT touched: units.mjs has a failing assertion of its own,
percent(0) returning "0%" where it expects "0.0%". It is at line 221 of
the file as committed and predates this change.
"No sessions yet. Start one in any project directory on your machine."
was drawn whenever the session list and Recent were both empty, and both
arrive over the network, Recent on a slower beat than the session list.
So a machine full of work opened on an invitation to go and start
something.

It is the same mistake as a card that renders while it is still loading,
and worse than that one was: this does not merely flicker, it states a
fact about your machine that is not true.

The store now records when a full round of the fan-out has finished,
history included, and the empty state waits for it. An empty sidebar for
a moment says nothing at all, which is what is actually known then.

Both halves are checked, because a gate like this fails silently in the
other direction too: with real history the list fills and no empty state
appears, and against a machine returning no sessions and no history the
empty state still appears after the round completes rather than being
hidden for ever.

Ruled out first: this was NOT the recent folder cap. The empty state is
keyed on the unfiltered session and history lists, not on the groups, so
capping folders could not have produced it.
The sidebar came up empty and a reload or two brought it back. The cause
is that the fan-out had no timeout anywhere in it. A browser fetch to a
machine that is asleep or off the tailnet does not fail quickly, it
hangs; the round is a Promise.allSettled over the fleet and the results
are assigned together, so until the slowest machine answered there was
nothing to render at all. Reloading "fixed" it only because a fresh
attempt sometimes failed faster.

Each machine now gets four seconds. That number is not a nicety: it is
how long the sidebar can be blank because a laptop in the fleet is
asleep. A healthy tailnet hop answers in tens of milliseconds, so four
seconds is well past the honest cases and short enough that a dead peer
is an inconvenience rather than an empty app.

A machine that misses the deadline is simply offline for that round,
which is a path that already existed and already does the right thing:
its last known rows are kept and its dot goes out, and the next round
tries again, so a machine that was merely slow returns on its own.

Measured with a peer whose requests never answer, which is what an asleep
machine looks like to the browser: the sidebar now renders in 4.5s where
before it waited on the browser's own patience. The residue is honest and
worth naming: with a dead peer in the fleet the first paint is still
delayed by the deadline, because the round assigns its results in one go.
Rendering each machine as it answers would remove even that, and is the
better fix if this is still felt.
…ealth

Two faults from one real run.

A review that ended before it finished showed "Nothing worth reporting.
Posting sends that as the review." Its record was still sitting at the
survey phase, so nothing had been read at all. That is the same lie this
view told once before, when a review parked on a permission question
claimed the code was fine; the cause is different now, the rule is not.
The recorded phase is what tells the two apart, so a run that did not
reach `done` says it stopped and where, rather than reporting a clean
result it never earned.

And starting a review for a pull request that is already being reviewed
now hands back the run in progress instead of starting a second one.
Clicking Review twice, or clicking it again after a push moved the head,
was making two sessions with the same name, two worktrees, two lots of
quota and two drafts of which only one could ever be posted. Bounded to a
LIVE session, so a finished review never blocks re-reviewing at a new
commit, which is the whole reason to click it again.

Both were found by watching a real review rather than by reading the
code: the sidebar had two "Review #5 Nightly" rows and the open one
claimed there was nothing to report.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant