Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.