Skip to content

In-app browser panel with agent control - #88

Merged
badcuban merged 85 commits into
mainfrom
feature/deck-sidebar
Jul 28, 2026
Merged

In-app browser panel with agent control#88
badcuban merged 85 commits into
mainfrom
feature/deck-sidebar

Conversation

@badcuban

Copy link
Copy Markdown
Collaborator

Adds an in-app browser panel that the agent can drive, and reworks the sidebar
around it.

The browser

A tabbed browser panel beside the chat, sharing the centre column. Tabs, an
address bar that finishes URLs you have typed before, a device toolbar for
sizing, and the page tools (annotate, region, draw, erase) that put a screenshot
and the elements you circled into the composer.

Agent control

Eleven-plus MCP tools reach the panel over an authenticated local HTTP endpoint:
snapshot, click, move, drag, type, press, scroll, evaluate, navigate, screenshot,
resize, tabs, select tab. Both Codex and Claude have driven it end to end against
a live page.

Two things worth calling out, because they are the load-bearing decisions:

The page is described by Playwright's own injected script, vendored at build
time from the installed playwright-core rather than pasted in. scripts/extract-playwright-injected.ts
extracts it, records a hash, and CI fails if the two drift. The alternative was
our own accessibility walk, which kept only named actionable nodes -- an agent
could see every button and not a word of what the page said. It runs in an
isolated world, so the page cannot read our handle or notice it is being
automated.

The agent is visible while it works. A pointer marks where it last acted,
travels between actions, replays drags as press-travel-release, and rests where
it finished. An activity line says what it just did in words. Its tab is marked
in the strip. The rule underneath all of it: the page the agent works on is a
page you can see it working on.

The sidebar

Icon-collapsible with a rail, a destinations band, and a diffstat chip on the
source control toggle. It folds to its rail on its own when both side panels
squeeze the chat, once, and never again after you overrule it.

Testing

vp fmt, vp lint, vp run typecheck and the full suite pass. Beyond unit
coverage, every browser tool has been exercised against a real page in the dev
app by both providers -- that is what turned up the ones that were shipped and
broken: an MCP notification answered with an empty 200 that Codex could not
parse, a vendored asset the desktop build never copied, and a drag tool that only
accepted elements when the thing it is named after needs coordinates.

badcuban added 30 commits July 22, 2026 15:18
Threads enter the deck when they go live (working, awaiting input,
pending approval, plan ready, background), have an unseen completion,
or are pinned. Rows keep insertion order - activity only changes the
status dot, never the position - and stay until dismissed, archived,
or auto-trimmed (settled, seen, unpinned rows beyond the cap). Deck
membership and dismissals persist in ui-state localStorage. Deck rows
lead the jump-key and thread-traversal order, deduped against their
project-tree rows.
- Add interactive HTML mockups comparing browser panel layouts and sidebar variations
- Demonstrate expanded, collapsed, overlay, and deck-style sidebar states
The sidebar collapsed to nothing, so reclaiming width for a wide panel
meant losing every signal about the threads you were not looking at.
Collapse now lands on a 44px rail carrying the question the sidebar
answers while you are heads-down in one thread: is anything else
running, finished, or stuck?

The rail is a second density of the same column rather than a companion
to it — expanded shows the pane, collapsed shows the rail, never both —
so nothing is rendered twice. On Deck rows keep their expanded order,
so a dot never moves under the cursor, and an aggregate badge stands in
for the per-row attention signal that collapsing takes away.

Project glyphs are deliberately absent: per-project status is derived
inside each project row, and duplicating that derivation for the rail
would be worse than shipping without it.
With a wide panel open, source control can sit far from its toggle, and
a bare icon says nothing about whether there is anything in the working
tree worth opening it for. The toggle now carries its own status.

The counts appear only while the panel is closed — once it is open it
lists per-file counts and repeating the total is noise — and only for a
dirty repository, so a clean tree stays visually quiet and an unloaded
status never reads as "no changes".
A thread on deck rendered a second row under its project, so the two
most prominent parts of the sidebar showed the same work twice and it
read as duplicate threads rather than one thread in two places.

Deck rows now own those threads outright. The filter runs before the
preview window so a deck thread does not consume a preview slot and
then vanish, which would leave "Show more" counts disagreeing with the
rows on screen, and the jump-label pass filters identically so keyboard
indices keep matching what is rendered.

Two things deliberately keep using the unfiltered list: the project's
aggregate status dot, because a project with a running thread is still
busy wherever that thread is displayed; and the "no threads yet" empty
state, because a project whose only threads are on deck does have
threads and simply renders no rows of its own.
General Chats was a fixed place in the app wearing a project's clothes:
a tree group that expanded, carried a thread count, and sat directly
under On Deck. On Deck changes height as threads come and go, so that
was the one slot in the sidebar that moved the most.

Fixed navigation now lives in a band above the deck, where it can stay
in the same pixel every time, and general chats reach it as a Chats
destination with its own list view. The band is deliberately plain --
one line, no counts, no disclosure -- so it reads as chrome and never
competes with the live work below it. The collapsed rail renders the
same destinations as icons, so both densities offer the same places in
the same order.

The band holds one destination for now; Pull requests and Scheduled are
meant to join it, which is why the list is data-driven rather than
hand-rolled rows.

Two consequences worth naming: general chat threads no longer take jump
indices, because they no longer render a row in the tree; and the rail
keeps its own New thread button rather than promoting "New chat" into
the band, since the frequent action here starts a project thread, not a
general chat.
A deck row shortens the title to fit 218px and the rail keeps nothing
but a status dot, so the sidebar's most compressed surfaces were also
the ones that said least about what they pointed at. Hovering a thread
now restores exactly what the row had to drop: the full title, its
status and last activity, the project it belongs to, its branch or
worktree, the machine when the thread is remote, and the model.

Built on Base UI's preview card rather than the tooltip, because a
tooltip is meant to be a short string and this is a small surface with
structure. Its open delay is longer than a tooltip's so the card does
not fire while the pointer is only crossing the rail on its way
somewhere else.

Deck rows also gained the project favicon inline. The project name was
already there, but as mono text it read as metadata rather than as the
project, which made a cross-project deck harder to scan than it should
have been.

The model is only known once a thread's shell has loaded, so the card
reports the provider until then, and it reports it the way the model
picker does -- "Claude", never the internal "claudeAgent".
Two fixes to how the sidebar's top reads and behaves.

The destination band had no boundary of its own. Every other block in
the sidebar opens with a section label, so a lone row sitting in the gap
between search and the deck read as something that had lost its
section rather than as navigation. Measuring showed the rows were
already aligned to the same grid, so the fix is the missing boundary,
not indentation: the band now sits tight under search and a rule closes
it, which groups the two as the top chrome they are.

Hover cards now share a delay group per list. The delay exists to stop a
card firing while the pointer is only crossing the list on its way
elsewhere; once one card is open that intent is no longer in doubt, so
re-imposing the wait on the next row just punishes deliberate scanning.
Neighbouring rows now swap instantly and the group stays warm briefly
after the last card closes. Measured: 346ms for the first card, 2ms for
the next.

That grouping is why these are tooltips rather than preview cards --
only the tooltip primitive ships the shared-delay provider. The trigger
keeps an aria-label carrying the thread title, so the accessible name
stays complete even though the card's structure is exposed as a
tooltip's description.
On Deck is the working set for project work. A general chat belongs to
no project and answers no question about a repo, so it has no business
sitting between two threads that do -- it made the deck look like a
mixed inbox rather than the small stable set it is meant to be. The
deck sync now only ever sees project threads, and because the sync
drops keys absent from its input, a chat already on the deck evicts
itself without a migration.

Also two adjustments from looking at it in use. The hover card's title
was a size larger than everything below it and dominated a card whose
job is to be scanned; the hierarchy now comes from weight and colour at
a single size. And the destination band, having been given a rule last
round, was still pinned against the search field with no air on either
side -- it now has padding of its own so the rule reads as the edge of
a band rather than a line drawn under a cramped row.
General chats are backed by a hidden project, and that implementation
detail was surfacing everywhere the user looks: the crumb read "General
Chats / New thread", a chat's hover card named the project as though it
had been chosen, the recent list under a chat advertised project
threads, and a terminal toggle offered to run commands in a checkout
that does not exist.

Chats now present as a mode. The crumb reads "Chats / New chat", the
hover card omits the backing project, recent lists no longer cross the
divide in either direction (a project draft stops advertising chats
too), and the terminal control is absent rather than disabled -- absent
because a chat has no checkout at all, where a project thread without a
checkout keeps the disabled toggle that explains itself.

Because chats no longer reach the deck, a running chat had nowhere left
to signal from, so the Chats destination carries the aggregate status of
the chats behind it. That is a badge on a band argued to hold none, but
the alternative was activity with no representation anywhere in the
sidebar, and the same "the control carries its own status" shape already
serves the branch toggle and the rail's attention badge.
Removing the terminal from general chats rested on a false premise. The
claim was that a chat has no checkout to run anything in, but general
chats run in a scratch workspace, so the terminal was working the whole
time and was useful there. It is back, and the prop added to gate it is
gone rather than left behind always true.

"Chats" also asked users to hold a distinction the word does not carry:
a thread inside a project is a conversation too, so a list called Chats
invites them to look for project work in it. "General chats" says the
thing that actually separates them -- these belong to no project -- and
it fits the sidebar without truncating.
The rail dropped the project tree entirely, so collapsing the sidebar
meant losing every signal about projects you were not currently inside.
Each project now keeps a glyph below the deck dots, carrying its
favicon and the same aggregate status its expanded row shows; clicking
one opens the pane with that project already unfolded rather than
navigating somewhere unasked.

That status was the reason the glyphs were cut in the first place: the
derivation lived inside the project row and duplicating it would have
let the two drift. It is now a shared helper both callers use, so the
glyph and the row can only ever agree.

The rail is also narrower than macOS traffic lights, so painting the
rail colour behind them split the titlebar into two greys that met
mid-button. The rail now starts below the titlebar in Electron, leaving
one continuous strip across the top. Scoped to the left sidebar: the
right panel is a collapsible container too and would otherwise have
picked up the same gradient.
The previous attempt at this only looked correct in the computed style.
The rail surface is painted by the sidebar's inner element, not the
container the cutout was applied to, so the inner element went on
covering the titlebar and the traffic lights kept sitting on rail grey.
The cutout now applies where the colour is actually drawn.

The rail's right border was the other half of it: a border spans the
element's full height, so the edge ran up through the titlebar and met
the yellow button. It is drawn as a background layer instead, starting
at the same offset as the surface, leaving the top band uninterrupted
across the whole window.

One residual: the band ends at the titlebar height while the chat
header's rule sits about three pixels higher, so the two horizontal
edges do not quite meet at the corner. Aligning them means keying off
--chat-header-bottom, which is measured at runtime and is zero until a
chat header mounts -- on a route without one the rail would paint to the
top again and bring the original problem back.
Four fixes to the general chats surfaces.

The list read as a continuation of the page title: a heading, a
subtitle, a rule, then rows of nearly the same weight. A count label
now heads the list the way section labels do elsewhere, with room above
it, and the row titles sit a shade back from the title so the hierarchy
is legible rather than implied.

Starting a chat also meant opening the destination first, which project
rows never ask of you. The Chats row now carries the same
hover-revealed action its project siblings do; the status dot yields
that corner while the action is showing rather than the two stacking.

The collapsed rail advertised a resize it cannot perform -- the drag
handle was still mounted at a width the rail does not honour. It is
hidden while collapsed, so the only thing on that edge is the border.

That border is now macOS-only in its offset form. Only macOS draws
window controls over the app's top-left corner; Windows and Linux put
them on the right, so there the rail is free to run to the very top.
A project row shows a name and a count; the rail shows a favicon and
nothing else. Neither says where the project lives, how much of it is
moving, or when it last did anything -- which is most of what you want
before opening one. Hovering a project now answers all three, in both
densities.

The card hangs off the row's sticky wrapper rather than the row button.
The button is where dnd-kit puts its activator ref, and Base UI merges
event handlers but explicitly does not merge refs, so triggering from
the button would have quietly broken project drag-reorder. The wrapper
carries no drag props and covers the same area.

The rail's top edge now lands on the chat header's rule rather than the
titlebar height, so the divider reads as one line across the window
instead of stopping where the rail begins. That needed the header's
measured position, so --chat-header-bottom now defaults to the titlebar
height instead of zero: the rail stays correctly inset before any header
mounts and on routes that have none, which zero would not have survived.

The summary builder moved to Sidebar.logic where the pure sidebar logic
lives and where it could be tested -- its counting, its notion of "live",
and its empty-project case were all untested while it sat in the card.
The divider left an unpainted pixel exactly where it met the chat
header's rule. The horizontal line was painted by the rail's inner
element, which spans the content box, while the vertical edge was
painted by the container starting below the divider -- so the one pixel
belonging to both was claimed by neither. The divider is drawn on the
container now, where it reaches across the edge strip.

Hover cards were also grouped per list, so the delay reset whenever you
crossed from one list to another: deck row to project row, or project to
project, each waited again. There is now one group per sidebar surface,
which is the honest scope -- the delay exists to survive a pointer
crossing the sidebar, and the sidebar is one thing to cross. Measured:
359ms for the first card, 41ms to a project card, 36ms back to a thread.
Groundwork for the center split. The panel's open state, address and
viewport preset are per-thread because what you are looking at belongs
to the work you are doing: opening a second thread should not inherit
the first one's page.

The split ratio is deliberately not per-thread. It is a window
preference, and columns that jump width when you switch threads would
be worse than one ratio you set once. It clamps to a third either way,
since a browser squeezed to a sliver is worse than a closed one.
The browser sits beside the chat rather than in the right rail: it is
the other half of the work surface, not an annotation of it, and the
rail belongs to source control. Both panes honour one ratio, stored as
a fraction so the split survives a window resize, and clamped so a hard
drag cannot collapse either side to a sliver.

The preview is an Electron <webview> rather than an iframe. An iframe
cannot be navigated, inspected or driven cross-origin, which is the
entire point of the panel; a <webview> is a real Chromium tab that CDP
can attach to later. Because it lives in the renderer rather than as a
native view over the window, it stays inside normal CSS layout and
dialogs still stack above it.

It runs in one persistent partition, so signing in to the app you are
building is something you do once rather than once per thread -- and so
the agent will later drive a tab that is already signed in. That session
is kept apart from the app's own, and preview content gets no Node, no
preload and a short permission allowlist.

Manual only for now: no agent control yet.
Verifying the webview in the desktop app showed it was running in the
app's default session: shared cookies and storage with Threadlines
itself, which is the isolation the panel was supposed to have.

The cause was assigning params.partition in will-attach-webview.
Electron resolves the guest's session from the element's attribute
before that event fires, so the assignment silently did nothing while
reading as though it had. The renderer now sets the attribute, and the
main process refuses any attach that asks for a different partition --
enforcement rather than assignment, so the attribute is load-bearing
instead of advisory. The constant is shared because both ends have to
agree on it.

Verified in the running desktop app: the preview loads a real page and
reports its own partition directory, and a webview requesting another
partition does not attach.
Chrome DevTools Protocol rather than executeJavaScript: it operates at
the browser level instead of inside the page's JavaScript sandbox, so it
is unaffected by same-origin policy, and the input events it will later
dispatch are real ones -- hover, focus and blur behave as they do for a
person, which synthetic DOM events do not.

Console output and failed requests are collected from the moment a tab
attaches rather than gathered on request, because by the time anyone
asks what went wrong the interesting line has already been printed. Both
buffers reset on main-frame navigation so they describe the page being
looked at, and are capped so a chatty page cannot grow without bound.

Tabs are addressed by webContents id. The renderer owns the <webview>
and is the only side that knows which element belongs to which thread,
so it registers the pairing and the main process stays an executor that
cannot act on a tab nobody claimed.

Verified in the running desktop app: attach succeeds, document.title and
element text read back through CDP, a console log and error are
captured, and a 404 is recorded with its status.
Electron writes security warnings straight into an unpackaged renderer's
console, so they landed in the buffer whose whole purpose is telling an
agent what the page under development is doing wrong. They are about how
Electron hosts the page, not about the page.

Filtered rather than silenced with ELECTRON_DISABLE_SECURITY_WARNINGS:
that variable is process-wide and would also disable the warnings for
Threadlines' own renderer, giving up a real safety net on our own code
to tidy someone else's console. Matching the brand string is stable
across versions, and if it ever changes a warning reappears in the
buffer -- today's behaviour -- rather than anything breaking.

Only warnings are dropped, never errors, so a page debugging its own
Electron integration still sees everything it logged.
Network.loadingFailed carries only a request id, and it was being stored
in the url field. A 404 hid the mistake, because that path arrives via
Network.responseReceived which does carry an address; only failures
below HTTP -- DNS, connection refused, a blocked port -- take the
loadingFailed path, and those were reported as an opaque id. Request
addresses are now remembered from Network.requestWillBeSent and resolved
on failure, then forgotten, so the map cannot grow with the page.

Also capture the Log domain. Runtime.consoleAPICalled only sees what the
page itself printed, which misses the browser's own complaints: CSP
violations, CORS refusals, blocked ports, mixed content. Those are
precisely the failures a developer asks an agent about, and the page
never logs them.

Verified against a page whose requests fail both ways: the blocked-port
request now reports http://127.0.0.1:9/x rather than a request id.
A snapshot from the accessibility tree, and clicks and typing addressed
by the refs that snapshot hands back. Targeting by ref rather than by a
selector string means there is nothing to parse and nothing ambiguous:
the agent can only act on something it was actually shown.

The tree is filtered to roles worth acting on, plus a few landmarks for
orientation. A real page's full tree runs to thousands of generic
containers, which would bury the handful of things that can be pressed.
Nameless controls are dropped because an element that cannot be
described cannot be chosen deliberately.

Input goes through CDP rather than element.click(): hover, focus and
blur fire as they would for a person, and handlers that check isTrusted
behave the same way.

Clearing a field uses the editing command directly. Dispatching Meta+A
as a raw key does not work -- the browser resolves shortcuts to editing
commands above the layer CDP injects at, so the chord selects nothing
and the insert lands wherever the caret was. A click centres the caret,
so the text arrived mid-word: old@examp + new@example.com + le.com.
A blank preview asks for the one thing nobody remembers: the port a dev
server happened to pick. It now lists what is actually listening, so
opening the thing you are working on is a click rather than a guess.

Discovery parses lsof's -F field output. The human-readable table
changes shape between versions and locales; the tagged format does not.
Tags arrive in sets, so every address belongs to the process last named,
and a process with no command clears the previous name rather than
inheriting it -- attributing a port to the wrong program is worse than
showing none. Ports listed once per address family collapse to one row,
established connections are skipped since they are not listeners, and
addresses this machine's browser cannot reach are dropped.

Local by design: the preview is a browser on this machine, so a remote
environment's servers would not be reachable from it anyway.

Also adds screenshot capture, taken through CDP rather than capturePage
so the image is the page itself even when the window is occluded.
Every tab keeps its own element, mounted whether or not it is showing.
Switching tabs would otherwise tear down the guest and take the CDP
attachment with it, losing the page and the console and network history
collected from it -- and reloading on the way back, which is the one
thing a preview must not do while you are watching something happen.
Inactive tabs are hidden with visibility rather than display so the
guest keeps its layout: a page collapsed to nothing reports a
meaningless viewport to a screenshot or to an agent measuring an element.

Only the visible tab drives the toolbar, so a background tab finishing a
load cannot repaint controls describing a different page. Closing the
active tab selects its right-hand neighbour, falling back to the left at
the end of the strip, so closing several in a row walks along instead of
jumping to an end. Closing the last one leaves a blank tab rather than
an empty frame.

Imperative webview calls are guarded: a guest rejects them until it has
attached and fired dom-ready, so a tab opened and immediately pointed
somewhere threw. Navigation happens anyway through the bound src, which
is why the failure was invisible except in the console.
Docking to the bottom, expanding over the chat, and a menu for the
things that belong to the whole tab rather than the page: open in the
default browser, copy the address, developer tools, and clearing the
preview's cookies.

The layout controls sit with the tabs and the page controls stay in the
toolbar, because they act on different things -- one moves the panel,
the other moves the page.

Dock side is a window preference like the split ratio, so it is
remembered; expansion is not. Reopening the app to a hidden chat would
look like the thread had vanished, which is a worse first impression
than re-expanding.

Clearing cookies reloads the tab afterwards: signing out is the point,
and a page left on screen still looking signed in would suggest it had
not worked. Developer tools open undocked, since the guest is a small
pane and docking them into it would leave no page to inspect.
Three things, all visible the moment a dark page loaded.

The guest was painted white. That was a literal bg-white on the element,
so a page with a dark background sat inside a white frame and the empty
state -- which overlays the guest -- rendered our light-on-dark text onto
white, leaving it nearly unreadable. The guest now uses the app's
background and the empty state paints its own, so neither depends on
what the page underneath happens to be.

The white line around a dark page was the same white showing at the
element's edges past the page's own painting. Gone with it.

Pages that respect prefers-color-scheme now follow the app rather than
the OS, emulated through CDP so a page that ignores the query is left
alone. A dark app hosting a stubbornly light page is the jarring part,
and it is the app the page is embedded in.

Docking to the bottom is removed. The bottom edge belongs to the
terminal, and a browser that could take it made two things compete for
one place. The browser splits the centre; that is the whole story.
A machine has many listeners that are not web servers, and showing them
all buried the two that mattered. Measured on a real machine: a dev
server answers 200 text/html, an AirPlay receiver answers 403 with no
content type, and a Handoff daemon does not speak HTTP at all. Serving
HTML is the line that separates them, so each candidate is now asked for
a page and kept only if it returns one. Fourteen entries became five.

That also fixes the labels. Three dev servers all called "node" are
indistinguishable; their titles are exactly what you were looking for,
so the page's title leads and the process name is the fallback.

Probes are cached per port and pid, so a stable server is not re-fetched
on every poll while a restart on the same port still updates within a
few seconds. Redirects are followed, since a dev server pointing at its
app shell is still a web server.

The trade-off: a server that answers with JSON rather than HTML no
longer appears. This panel opens pages, so that seems the right side to
err on, but it is a real exclusion rather than an oversight.

Also gives the status dot room; it was sitting against the row's edge.
badcuban added 18 commits July 27, 2026 00:51
Two doc comments describing constants that no longer exist, and one
comment duplicated where the file was stitched back together. All of it
invisible to the compiler and to lint, which is why it survived a
typecheck, a lint pass and a full test run.

Found by asking for a second pair of eyes on the restoration rather than
trusting that the diff being the right size meant the diff was right.
Five days and twenty-eight commits behind. Three files conflicted and
none of them badly: main deleted a draft-prompt helper we happened to
have added a callback beside, reordered the recent-threads list to put
in-flight threads first, and added a new-thread button to a project
header we had wrapped in a hover card.

Resolved toward main throughout. Their thread ordering replaces ours in
that list -- putting working threads at the top is a deliberate change
and our sort order was incidental to it -- and we keep only the scope
filter, which is what that component is for. Their new-thread button
keeps our hover card around it. Their focus-ring convention wins
everywhere, including in three files this branch wrote after their sweep
landed and which nothing had reconciled.

Also here: effectiveCwd on two sidebar test fixtures, which main added to
the thread projection while this branch was away, and the removal of
resolveProjectStatusForThreads -- written early on this branch, imported
once, never called. The project hover card reads a status the projection
already carries.
The tool returned base64 inside a JSON string. Thousands of tokens for
something the model cannot look at -- in our own agent's words, strictly
worse than the tool not existing.

MCP has an image content type for exactly this, and a toolkit cannot
reach it: whatever a tool returns becomes structuredContent, so a picture
arrives as a description of a picture. The screenshot is registered by
hand instead, which means doing what the toolkit does internally and then
splitting the image out into a content block, leaving the dimensions
behind as text so the model can reason about size without being handed
the bytes twice.

Verified over the protocol rather than by reading the code:
structuredContent is now {width, height}, and the content carries an
image block of image/png.

The thread comes off the request's own fiber. A hand-registered handler
does not run under the router middleware that provides it, so reading it
from the surrounding scope would get whichever request happened to build
the layer -- one thread driving another thread's browser, which is the
thing the credential exists to prevent.

Also fixed, and worse than the thing I set out to fix: Codex refuses
bearer_token on a streamable HTTP MCP server, and refuses it by failing
to start the thread at all. Every Codex session on this branch was dead
on arrival and I had only ever tested with Claude. Its own config schema
takes http_headers, which is what we send now.
Two bugs with one cause: the tab was resolved fresh on every request, so
whichever tab you had just clicked became the one the agent acted on, and
a navigation moved the page out from under whatever you were reading.

The agent now pins the tab it first acted in and keeps it. Automation
never changes which tab is selected -- the agent moves its own page, not
your view. The pin lets go only when the tab does, and then the next
action picks up whatever you are actually looking at.

Which leaves the other half: an agent working in a tab you cannot see is
indistinguishable from a page misbehaving on its own. So the agent's tab
carries a dot that pulses while a call is in flight, and a line under the
toolbar says what it just did in words -- clicked "Sign in", not
browser_click with a locator. Off its tab the line becomes the way over.
Nothing follows for you; a mode that moved your selection would be the
yanking this was built to stop.

Verified the pin against a live agent: it snapshotted and navigated its
own tab while the address bar stayed on the other one. The line and the
dot are covered by tests rather than by driving the app, after the drive
turned out to be measuring a second dev stack I had left running.
With source control and the browser both open, the chat column is what
pays, and it is the column the work is in. The rail gives back around two
hundred pixels and loses nothing: the destinations and the projects are
all still there as glyphs.

The restraint is the feature. It folds once, on the way in, and never
fights you -- expand it yourself and it stays expanded, with both panels
still open. Leaving the squeeze resets the arrangement, so the next time
is a new situation rather than a continuation of the one you overruled.

The rule is a pure function with the awkward cases written down, because
the second unrequested collapse is the one that makes somebody stop
trusting the control.
Asked to click something "in our browser", Codex drove the user's real
Chrome, reported on a signed-in personal session, and said it was using
the in-app browser while doing it. It was not being careless.

Codex ships a bundled browser plugin whose skill file claims "in-app
browser" as one of its own aliases and then says outright: do not use
external MCP browser-control tools for this surface. Having claimed the
words, it looked for the ChatGPT in-app browser, did not find one, and
fell back to Chrome -- still calling it the in-app browser, which is why
the message read as though it had done what was asked.

A tool description cannot answer that, because the model commits to the
skill before it reads the tool list. Developer instructions can:
developer-role text outranks a skill file, which arrives as tool output.
So the thread is told which surface the user is looking at, that Chrome
and Codex's own browser are different surfaces that cannot see it, and
which tools reach it.

Deliberately narrow, and it disables nothing. Asked for Chrome, the model
should still drive Chrome -- that is what those plugins are for. Turning
them off was the first thing I tried and it is the wrong shape of answer:
it fixes our problem by breaking somebody else's feature on a machine
that is not ours.

The tool descriptions now name the panel too, as insurance for the case
where the model does read them first.

Claude was measured on the same prompt and the same page before any of
this: it clicked our panel correctly, so this changes nothing for it.
The Codex side is reasoned from the skill's own text and is not yet
confirmed against a live run.
"I can't access the Threadlines preview panel right now -- the in-app
browser connection isn't available." It was telling the truth. The server
was never there.

MCP servers are read when the app server starts, the same as plugins. We
were naming ours in the thread/start config overlay, which arrives long
after that -- so every Codex thread began with no browser tools at all,
and the model, correctly refusing to substitute Chrome, had nothing left
to use. The same mistake twice in one evening, in the same place, for the
same reason.

We spawn one app server per thread, so the server is named in its argv
instead, which is early enough. The credential goes through the
environment rather than argv, because argv is readable by anything that
can run ps.

The thread-level path is gone rather than left as a fallback. It never
worked, and two mechanisms where one is dead is how the next person loses
an evening.

What the user saw is the half that already worked: told which surface
they meant, the model declined to reach for Chrome. That was the part
that needed persuading. This is the part that needed plumbing.
A JSON-RPC notification has no reply, so the transport answered with an
empty body -- but it answered 200 with content-type application/json,
promising a JSON document that was not there. Codex took the promise
seriously, tried to parse nothing, failed with "EOF while parsing a
value", and abandoned the handshake. It registered threadlines_browser
with zero tools, which is why the model kept saying the in-app browser
was unavailable. It was.

202 Accepted is what the spec asks for on a notification and it says what
is true: received, nothing to return. Measured before and after --
notifications/initialized moved from 200 to 202, and tools/list still
answers 200 with all eleven.

Diagnosed by asking the model in the running app to troubleshoot its own
missing tools, which it did by reading the wire. Worth recording that the
same fix was sitting in the competitor's source I had already read
tonight, in a function whose name told me exactly what it was for. I did
not connect it until somebody else found the failure it prevents.
The bundler bundles code and copies nothing, so the 311KB Playwright
script under src/ never reached dist-electron, and the bundled main
resolves it relative to itself. The file was only ever where the runtime
was not looking. Every browser_snapshot failed with ENOENT.

What makes that worth a commit message is how it presented. The model hit
the error, said "the semantic page reader hit a local packaging error,"
fell back to screenshots and status reads, and answered the question
correctly anyway. It clicked the right button and read the right word off
the page. Nothing looked broken. The most capable tool we ship was
missing for hours behind a graceful degradation.

So the copy runs on every dev launch, because pack --watch clears its
output directory and a rebuild restart would otherwise start an Electron
whose assets had just been deleted -- and the release build refuses to
stage an app without it, since a shipped binary would have failed the
same quiet way.

Four places needed to agree on where this file lives: the extractor that
writes it, the runtime that reads it, the build step that copies it, and
the packager that checks it. Two of them are repo scripts and two are in
the desktop app, so the module moved to @threadlines/shared, which is how
everything else here crosses that line. It was importing across package
roots before, which typecheck had been failing on quietly under a stale
cache.
Three complaints, one shape.

It looked like everyone else's because it was painted like everyone
else's: a hollow arrow with a coloured outline, which is what every system
cursor on every platform looks like. Same silhouette now, inverted paint --
solid in our colour with a rim around it, round joins and caps instead of
mitres, stroke under fill so the rim sits outside the shape rather than
eating it. Familiar as a pointer, obviously not yours.

It vanished after 1.4 seconds, which answered "did the click land?" only
if you happened to be looking when it happened. It rests now, dimmed, and
goes when the page navigates -- the coordinates are viewport-relative, so
resting through a navigation would mean pointing confidently at the wrong
thing.

And it teleported, because it remounted on every action. One element now,
gliding, with the duration scaled to the distance and clamped at both
ends: below the floor it reads as a jump, above the ceiling it is still
in flight after the page has changed.

Typing moves it too. `type` was already resolving the same centre point as
`click` and dropping it on the floor, so the pointer sat on the last thing
clicked while the agent filled in a field somewhere else.

The click ring runs once and unmounts rather than looping. Tailwind's ping
is infinite, and a permanently animating SVG over a live webview is the
exact shape of the idle churn that cost a quarter of a core per window the
last time it got into this app.
There was no mouseMoved anywhere in this codebase. Press and release, and
nothing in between. That ruled out every menu that opens on hover, every
tooltip, every slider, and selecting text by dragging across it -- so when
asked to select a word, the model reached for a DOM Range through
evaluate, which touches no mouse at all and left the pointer sitting on
the last thing it clicked. It said so plainly in its answer. It was right.

browser_move dispatches a hover. browser_drag presses, steps ten
interpolated moves, and releases. The intermediate moves are the feature:
a press followed straight by a release produces no selection and no
drag-and-drop, and every move carries buttons:1 or the page reads a hover
where a drag was meant.

A drag reports both ends, not just where it finished. The gesture is over
by the time the renderer hears about it, so the mark replays it -- presses
at one end, travels, releases at the other. The ring stays still while the
button is down. A pulsing ring during travel would read as repeated
clicking, and one continuous gesture is what happened.

The activity line names both ends too. `dragged` on its own says nothing
about what moved, and that line exists to answer exactly that.

Threading written by codex against a spec; the shape of the gesture, the
copy, and the replay are mine.
The pick outline sat about two pixels down and to the right of whatever it
was pointing at. Measured in the app's own Chromium, against an element
spanning 100..300 horizontally: the highlight ran 100..304, flush on the
left and four pixels long on the right, and the same again vertically.

A border. The overlay lives in a shadow root under `all:initial`, so
box-sizing is content-box, and setting width to the element's width gives
a border box four pixels wider than that. The frame grows outward on the
right and bottom while overlapping the element on the left and top, and
the eye reads the difference as the whole thing being nudged diagonally.
Region members at 1.5px skewed by a different amount than a single pick at
2px, which is why it never looked consistently wrong.

An outline occupies no space and surrounds the box evenly. Same in the
reveal flash, which had it too. The drag band keeps its border and gets
border-box instead, since that line belongs inside the rectangle you
dragged rather than around it.

Found by Will noticing it looked off, which it had since the day it was
written.
Asked about a tab that had just been opened, the agent answered about a
different one and said no other page existed. Both halves of that were our
doing.

It pinned itself to a tab so a turn in flight could not lose its page when
you glanced elsewhere, and then never let go -- the pin only cleared when
the tab closed. And it had no way to ask what else was open, so "there is
no panel at that domain" was the honest report of everything it could see.

Now: an idle agent releases its pin when you switch tabs, so its next
action lands on the page in front of you. Mid-turn it still holds, which
is what the pin was for. browser_tabs lists every open page with the one
you are looking at and the one it acts on both marked. browser_select_tab
moves it deliberately, and brings that tab to the front while it does.

That last part is the rule worth keeping: the page the agent works on is
the page you can see. An agent clicking something destructive on a tab
nobody is watching is a bad way to find out we allowed it.
Clicking a link is the most common way a page navigates, and the mark was
cleared the instant the URL changed -- so the one action you most want
confirmed was the only one that left nothing behind. It looked broken,
and it was the old "pops in and disappears" complaint wearing a new coat.

It still cannot stay. The coordinates are viewport-relative, so once the
page changes the mark would be pointing confidently at whatever now
occupies that spot. But it can leave over four hundred milliseconds
instead of within a frame, which reads as "that happened, and then the
page changed" -- the true account. A fresh action outranks a fade still in
flight.
Roughly 1.7x slower, and eased at both ends rather than only the far one.
A pointer that starts at full speed and merely decelerates reads as
something being dragged; gathering pace and then settling is how a hand
moves, and that is most of the difference between smooth and animated.

Short hops go from 140ms to 220, the long haul is capped at 560 rather
than 320. The cap still exists and still matters: the point arrives only
after the click has been dispatched, so a click that navigates has the
page changing underneath a mark still on its way there. Slower is better
to watch right up until the mark is reporting on a page that has gone.
Asked to drag across two words of a heading, the agent turned the drag
tool down and scripted a DOM Range instead. Its reasoning was right: drag
took elements and went centre to centre, and "Fire Alarm" is the first two
words inside a larger span, so there was no element whose centre was where
the selection had to start.

A mouse works in coordinates. Naming only elements quietly ruled out
everything that happens between them -- selecting part of a line, drawing
on a canvas, dropping onto empty space -- and left the tool looking
capable while being unable to do the thing it is named after.

Targets now include `{point: {x, y}}`, and the pointer operations take it.
Anything needing a real node refuses it by name rather than hit-testing
the coordinates, because whatever is under a point is as likely to be an
overlay or the body as the thing meant, and that is wrong in the way that
looks like it worked.
Typing facpmanuals.com in full, every time, for the sake of a browser that
had already been there twenty times.

Not history in the browser sense: no list to browse, nothing to manage, no
UI at all. Just the part of it people actually use. Three letters and the
rest is there, with the part you did not type selected -- so carrying on
typing overwrites the guess and Enter accepts it, which is why a real
address bar never feels like it is arguing with you.

Ranked by visits first and recency second. The site you go to daily should
beat the one you opened once an hour ago, and among sites you use equally
the recent one is the one you mean. When the list fills it drops the
lowest-ranked rather than the oldest, so a morning spent somewhere else
cannot cost you the site you live in.

Nothing under two characters, because a guess that changes on every
keystroke reads as the field being possessed. Nothing while you are
deleting, because deleting is a correction and putting the guess back
makes Backspace look broken. Visits are recorded where navigation lands
rather than where you aimed, so a redirect teaches it the real address.
Two things, both about the app having outgrown its own defaults.

The agent's tab was being taken from it. Opening a tab in the gap between
two of its own actions handed it the new one mid-task, because the release
rule read "idle" from the last operation rather than the turn -- and every
operation reports done the moment it finishes. It survived only because
switching back handed it the right tab again.

The rule is gone rather than corrected. It existed because the agent could
not see other tabs when I wrote it, so trailing the user was the only way
it could ever end up somewhere useful. It can list them now and move
itself deliberately, which is the arrangement people already expect from
tabs: yours are yours to browse, its tab is marked in the strip and one
click from the activity line, and it goes only where it decides to go.

And the first window was 1100x780, set when this was a chat with a
sidebar. It now opens onto four columns, which at that size arrive already
fighting for width -- so the first thing anyone does is drag it bigger.
It opens at nine tenths of the screen instead, capped so a 6K display does
not get a window the size of a wall and floored so a small laptop still
gets something usable. Remembered sizes are untouched; this is only what
happens on a machine that has never opened one, which is exactly what the
dev build is every time it gets a fresh state directory.
@vercel

vercel Bot commented Jul 28, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
threadlines Skipped Skipped Jul 28, 2026 3:45am

Request Review

@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL labels Jul 28, 2026
Three browser tests looked for a pinned thread's row in the project tree.
It is not there, and that is the point of the deck: a pinned or running
thread gets a deck row, and a thread on deck is drawn once rather than
twice. They were asserting the shape of the sidebar this branch replaced.

The hover-group test keeps its subject and loses its pin, since the row it
is about only exists for an ordinary thread. The two that asserted pin and
archive on a running thread's tree row are gone, replaced by one that
checks what is true now: the thread moved to the deck rather than
vanishing, and a run in progress offers no dismiss.

Found by CI, not locally -- `vp run test` is the unit projects, and the
browser project runs behind `test:browser`. Worth remembering before
calling a branch green.
@badcuban
badcuban merged commit 7fa4712 into main Jul 28, 2026
15 checks passed
@badcuban
badcuban deleted the feature/deck-sidebar branch July 28, 2026 03:54
badcuban added a commit that referenced this pull request Jul 28, 2026
vp run test is the unit projects. The web browser tests live behind
test:browser, CI runs them, and a branch called green on unit tests alone
shipped three failures to PR #88 before CI caught it. The gap belongs in
the task-completion list, where every agent reads before claiming done.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant