Conversation
…ng it as 403 The desktop panel failed with a 403 on the clipboard websocket and a 503 on the noVNC assets. Both had the same cause and both reported it badly. DESKTOP_VNC_URL was never a setting. main.py read getattr(settings, 'desktop_vnc_url', None) or os.environ[...] — and since the field did not exist on the model, the getattr always returned None. It was in neither config.py nor .env.example, so the only way to learn the variable was required was to read the proxy handler. A setting you cannot discover is a setting nobody sets. It is now a declared field, documented, with both correct values for the two deployment shapes. The 403 was worse than useless. The handler closed the websocket WITHOUT accepting it first, and Starlette turns that into a bare HTTP 403 — so the browser reported 'Forbidden' for what was really 'nobody configured the desktop', and anyone debugging it goes to check tokens and tailnet ACLs, which are fine. It now accepts first so the close reason reaches the client, and says what to set. The 503 on the asset proxy now carries a fix, not just a complaint: the message names the variable, a working value, and the fact that the sandbox has to be running. That text appears in a browser console, where 'not configured' alone sends people into the UI code. 294 tests green.
Reported from a live session: the terminal announced 'Connecting to sandbox...'
and dropped the user on a shell on the HOST.
_spawn_terminal_session ended with a branch labelled 'local fallback (dev mode
without docker)' that spawned $SHELL on the machine hosting TeamWork with
env={**os.environ, ...} — so that shell inherited every credential the service
holds, and anyone who could open the panel could open it. It triggered on the
ordinary case of SANDBOX_CONTAINER being unset, so a deployment that had simply
not configured a sandbox handed out a host shell instead of a sandboxed one.
Worse: `mode` was a client-supplied query parameter selecting between
docker-exec and that local shell. ?mode=local reached the host path even on a
deployment with a sandbox correctly configured and running. Where code runs is
never the caller's to choose.
Both are gone. No sandbox, no terminal — a terminal outside the sandbox is not a
degraded terminal, it is a different and much more dangerous thing, so the
honest answer when the sandbox is missing is to refuse and say why. `mode` is
accepted for URL compatibility and ignored; it no longer reaches the spawn path.
The main test is structural: it parses the module and fails if any Popen names a
shell directly, or if `**os.environ` or $SHELL reappear anywhere. A behavioural
test only covers the branch you thought to exercise, and this lived in the
branch nobody did.
298 tests green.
The trace list showed clock time only, which is ambiguous the moment the list holds more than one day of runs — '14:32' does not tell you whether that was an hour ago or last Tuesday, and the list is exactly where you go to find a run you remember by when it happened. Today's runs keep a 'Today' prefix so the common case stays scannable rather than becoming a wall of identical dates, and the full locale timestamp is in the title attribute. The detail header had no timestamp at all. It named the trace and its source and never said when it ran, so the single question you open an old trace to answer was the one thing that view could not tell you. Span times inside a trace stay clock-only: they sit under a header that now carries the date, and repeating it per node is noise.
On mobile, dragging up and dragging down did the same thing — both pulled the view down instead of scrolling. There are no touch handlers in the app, so nothing had inverted a gesture: the DOCUMENT was scrolling instead of the pane. #root asks for min-height:100vh, and on iOS 100vh is the LARGE viewport — the height as if the URL bar were hidden. So the page is taller than the visible area even when nothing overflows, and the workspace shell (which manages its own overflow) sits inside a scrollable document. A drag moved the document, and a document with almost nothing to scroll does not scroll, it rubber-bands. Both directions were a pull; neither was a scroll. Only overscroll-behavior-x was set, so nothing forbade the vertical bounce. The workspace now adds an app-shell class to <html> while mounted, pinning the document to the visible height and disallowing vertical overscroll — the gesture has nowhere to go except the pane that should have had it. Scoped to this route so the lander and project list keep normal page scrolling. NOT verified on a real device: this is mobile-Safari viewport behaviour and there is no touch device here. The inner containers are already correctly bounded (flex-1 min-h-0 overflow-y-auto), so pinning the document is the piece that was missing. 37 frontend tests green, tsc clean.
…anes scroll Two bugs in the trace panel on mobile, both instances of patterns worth sweeping for across the app. A BAND OF EMPTY SPACE TO THE RIGHT The resizable column width was applied as an inline style. Inline styles apply at every breakpoint and outrank Tailwind, so `w-full` never won and a phone got the desktop resizer's pixel width — a column wider than the screen, with `shrink-0` stopping it from adapting. It is now handed to CSS as a variable that only the md: class consumes, so mobile is genuinely full-width. THE TOP CUT OFF, WITH NO WAY TO SCROLL TO IT The columns are flex children without min-h-0. A flex child defaults to min-height:auto and so refuses to shrink below its content; combined with overflow-hidden the surplus is CLIPPED rather than scrolled, and the inner overflow-auto never gets a bounded parent to scroll within. This was survivable before the document was pinned — you could drag the page itself past the clipped region — so pinning the document (correct on its own) turned a bad scroll into no scroll. Adding min-h-0 lets the column shrink so the inner scroller does its job. 37 frontend tests green, tsc clean. Neither is verified on a real device.
Two mistakes produced three separate 'mobile is broken' reports, so this fixes the patterns rather than the panels that happened to be noticed. FLEX COLUMNS THAT CLIP INSTEAD OF SCROLLING A flex child defaults to min-height:auto, so it will not shrink below its content; with overflow-hidden the surplus is clipped and the inner scroller never gets a bounded parent. Seven more panels had it — Desktop, Terminal, Browser, Progress, TaskBoard, LibrarySpaceView, LibraryPanel — each one a trace-detail-shaped bug waiting for the right content to trigger it. FIXED-WIDTH SIDEBARS IN A HORIZONTAL ROW w-56 / w-64 / w-72 beside a content pane leaves that pane a sliver on a 390px screen. Observability, LiveSessions and Memory now stack: a full-width band above the content, and the row only forms at md. POPOVERS WIDER THAN THE SCREEN ModelPicker, EmojiPicker, ClaudeCodeStatus, the slash-command menu and Memory's tooltip all carried fixed widths that push the page wide on a phone. Each is capped to min(width, 100vw - 2rem), unchanged anywhere roomy. TAP LATENCY AND TARGETS touch-action: manipulation on controls drops the double-tap-to-zoom gesture and with it the ~300ms wait before a tap registers — the app felt sluggish for no reason other than the browser watching for a second tap. Layout-neutral, so it cannot regress anything visually. The chat action row (the most-tapped surface) goes from ~24px to 40px on phones and is unchanged at md. TWO STRUCTURAL GUARDS frontend/src/__tests__/mobile-layout.test.ts fails the build if an inline pixel width or a min-h-0-less clipping column reappears. They pass now, which is how I know the sweep was complete rather than merely thorough-feeling. They assert against source because jsdom has no layout engine and cannot tell you something is off-screen. NOT verified on a device — there is no touch hardware here. Deliberately left: the other ~38 small icon buttons (a blanket resize risks dense layouts I cannot see) and any information-architecture change, e.g. trace detail still hides its header behind hidden md:block. 39 frontend tests, 298 backend tests green.
…ways BACK LEFT THE APP The workspace persisted which panel you were on to localStorage — which survives a reload but is invisible to the browser. There was exactly one history entry for /project/:id, so Back from six panels deep landed on the project picker, losing the whole path you took to get there. On a phone Back is THE navigation gesture, so this was the app losing an argument with the platform. Each view change now pushes an entry and popstate restores it, including the graph runs -> nodes -> detail drill, so Back walks back the way you came. The subtlety is not rebuilding the stack while unwinding it: applying a popstate must not itself push, or Back appears to do nothing. A guard flag covers that, released on the next frame so the push effect sees it. Deliberately not in the URL. These are panel toggles rather than addressable locations, and inventing a query-param vocabulary for each would commit us to URLs we then have to keep working. SCROLLS DRIFTED SIDEWAYS A finger never travels exactly straight, and the browser was reading the slight sideways component of a vertical scroll as a horizontal pan — small, constant, and enough to make reading feel unsteady. Vertical scrollers now declare touch-action: pan-y. Scoped carefully: touch-action on an ancestor constrains its descendants, and chat messages carry wide code blocks, tables and mermaid diagrams that must keep panning sideways. The rule covers .overflow-y-auto only and explicitly releases .overflow-x-auto and .overflow-auto, so reachability is not traded for a wobble. 39 frontend tests green, tsc clean. Not verified on a device.
A dated backup made during an incident — cp .env .env.bak-$(date +%s) — is a full set of live credentials sitting in the working tree, and it showed up as untracked here. It only has to meet one `git add -A` once, in a PUBLIC repo. Prax already ignored .env.bak*; this repo did not, so the identical habit was safe in one repo and a leak in the other. That asymmetry is the actual bug: the person doing it has no way to know which repo they are in. Also covers .env.backup*, .env.save and .env.orig — the other names editors and scripts reach for. The two backups on the live box have been moved out of the repos entirely, to ~/.prax-env-backups (0600), which is where they should have been written.
CHAT SLID SIDEWAYS — the real cause, not the gesture Nothing in the app breaks long strings. A pasted URL has no break opportunity, so it forces a message wider than the screen; and because CSS computes overflow-x to auto whenever the other axis is not visible, the overflow-y-auto message list quietly becomes horizontally scrollable the moment that happens. The earlier touch-action: pan-y suppressed the pan but left the content unreachable off-screen — treating the symptom. Long strings now wrap (overflow-wrap: anywhere), so there is nothing to pan TO. Scoped to prose and message text; tables and code blocks keep their own horizontal scrolling. THE TRACE SUMMARY WAS A ONE-LINE SLIVER Reported with a screenshot: the summary compressed until not even a full letter showed, under a full-height list of 21 span names, and scrolling did nothing because the section had no height to scroll. An overflow-auto flex item has an automatic minimum size of ZERO, so the summary was the first thing crushed when height ran short. The Delegated Spans list below it has visible overflow, so its minimum is its content height and it refuses to shrink at all. The summary lost a fight it could not win. On a phone the detail pane is now one scrolling column — sections at natural height, the column scrolls. Summary and spans are both shrink-0 so neither can crush the other; live output gets a real minimum instead of filling height the column no longer allocates. The desktop three-pane layout is unchanged at md. Same family as the earlier min-h-0 bugs, opposite direction — which is why the structural guard did not catch it. That guard tests what I knew to look for. 39 frontend tests green, tsc clean.
…omposer THE KANBAN ONLY PANNED IN ITS TOP THIRD — my bug touch-action: pan-y constrains every DESCENDANT, not just the element it is set on. So horizontal panning survived only where a finger landed on the scroller itself and died everywhere below it. Suppressing a gesture app-wide to fix one surface takes it away from the surfaces that need it. Removed entirely rather than narrowed. The chat drift it was added for is now fixed at its source — long strings wrap, so nothing is wider than the screen and there is nothing to drift toward. A comment marks the rule as one not to reintroduce, since the next person will meet the same wobble and reach for the same tool. CODEX WAS NAMED BUT NOT SERVED The panel says "Claude Code, Codex or another harness" and then offered a claude-mcp-add line, which is useless to Codex. Telling someone their tool works and not saying how is worse than not mentioning it. Codex is configured by file (~/.codex/config.toml) and speaks stdio, so an HTTP server is reached through the mcp-remote bridge. Both forms are now shown and labelled, and the API returns both. A PDF PREVIEW COST YOU THE CHAT BOX The preview iframe was a fixed 600px — taller than most phone viewports — so opening a file pushed the composer and its send button off the bottom, and the space stayed unusable until you closed the file. Previews are now capped against the viewport (55vh for PDFs, 50vh for image and video), unchanged on desktop. A preview should never cost you the ability to type. 3 new tests; 301 backend + 39 frontend green, tsc clean.
…ling off THE TAB BAR SAT ON TOP OF WHAT YOU WERE TYPING The mobile tab bar is fixed bottom-0, which anchors it to the LAYOUT viewport — and that does not shrink when the keyboard opens. So it covered the composer and you could not see your own text. App.tsx now detects the keyboard from the gap between the layout and visual viewports (150px: comfortably above the browser chrome that also comes and goes, comfortably below any real keyboard) and marks it on <html>. The bar hides while typing and the padding reserved for it is reclaimed at the same moment, so no strip of dead space is left behind. It returns when the field is dismissed. Navigation you cannot use mid-sentence is worth less than seeing your sentence. THE SPACE CHAT WAS A FIXED 320px Right for a glance, wrong whenever the chat IS the work. It now drags between 280 and 880px and the width persists per space, because how much room a conversation deserves is a property of what you are doing there. The width is handed to CSS as a variable consumed only at md:. An inline width applies at every breakpoint and outranks w-full — the exact bug that put a dead band beside the trace panel — and the mobile-layout guard caught me doing it again with the drag handle, which is what a guard is for. vh IS THE WRONG UNIT ON iOS The mobile chat pane was h-[40vh]. On iOS vh is the LARGE viewport — the height as if browser chrome were hidden — so the pane is taller than 40% of what you can actually see, and with shrink-0 it can be pushed past the bottom edge. Now 45svh with a floor: svh is the viewport that is always really there. This may or may not be the reported "no chat on mobile"; the pane is otherwise structurally sound and I could not reproduce the cause without a device. 39 frontend tests green, tsc clean.
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.
A day of work on one branch. Grouped by theme.
Security: the terminal handed out a host shell
Reported from a live session — it announced "Connecting to sandbox…" and dropped you on the host.
_spawn_terminal_sessionended in a branch labelled "local fallback (dev mode without docker)" that spawned$SHELLwithenv={**os.environ, ...}— inheriting every credential the service holds, reachable by anyone who could open the panel. It triggered on the ordinary case ofSANDBOX_CONTAINERbeing unset.Worse:
modewas a client-supplied query parameter selecting between docker-exec and that shell, so?mode=localreached the host even on a correctly configured deployment. Where code runs is never the caller's to choose.Both gone. No sandbox, no terminal. The test is structural — it parses the module and fails if any
Popennames a shell or if**os.environreappears, because a behavioural test only covers the branch you thought to exercise and this lived in the branch nobody did.MCP — other agents can work in a space
Off by default behind three independent gates. Keys scoped to one space; a scoped key naming no space is refused, because an omission must never silently widen a narrow key. Writes are git-backed so a bad one is revertable.
Enabling is a button, not a hand-authored credential file — it mints a scoped key, writes the registry (0600, hash-only, atomic, preserving hand-authored entries) and shows the connect command once. Both Claude Code and Codex get instructions; naming a tool and then not saying how is worse than not mentioning it.
Each space also generates a paste-ready agent skill teaching what belongs on a human's board — the agent's plan for the next ten minutes is working memory, and working memory does not go on someone else's wall.
Mobile — swept, not patched
Most users will be on phones, so this went after patterns rather than reported panels:
Plus: long strings now wrap (the actual cause of sideways drift — I first suppressed the gesture, which broke Kanban scrolling and was treating a symptom), the tab bar hides while the keyboard is up instead of covering the composer, previews are viewport-relative so a PDF can't bury the chat box, and Back walks the panels instead of ejecting you to the project picker.
Two structural guards fail the build if the inline-width or missing-
min-h-0patterns return.Also
Trace dates (list and detail header, which had no timestamp at all); model badge on messages; resizable per-space chat;
.gitignorenow covers every.envderivative — a dated backup showed as untracked in a public repo and only had to meet onegit add -A.298 backend + 39 frontend tests green. Deployed and in use on the live box throughout.
Not verified on a device — there's no touch hardware here, so the mobile work is correct-by-construction, not seen-working.