Repository navigation
Conversation
…ymmetric width resize (ParadevAI#6746) * feat(desktop): grow the new-workspace box with content and add symmetric width resize The prompt editor in both create surfaces was capped at 200px, so longer prompts scrolled almost immediately — the top gripe with the modal. The editor now grows with content (modal: up to the dialog's min(80vh,720px) cap, top-anchored so it expands downward; screen: up to min(50vh,600px)) before scrolling internally. Both surfaces also get invisible drag handles on their vertical edges: dragging either edge resizes the centered box symmetrically (2px width per 1px of travel so the edge tracks the cursor), double-click resets, and the chosen width persists per surface in a fixed-size localStorage singleton (registered in the persisted-key allowlist). * fix(desktop): keyboard resize, flex-sized editor, and no width transition on the new-workspace modal - Arrow keys on the focused resize handles step the width (16px, 64px with Shift) so the focusable separators actually do something. - The modal editor now sizes through a min-h-0 flex chain instead of a fixed calc cap, so linked-issue pills and attachments take space from the editor rather than pushing the dialog into a second scrollbar. - transition-none on the DialogContent: the base duration-200 animated width, which made drags lag the cursor and keyboard steps read mid-transition values. * refactor(desktop): scope the new-workspace growth + resize to the screen only Drop the modal-side changes (top-anchoring, flex-chain editor sizing, transition-none, modal width) so the experiment's control arm stays untouched; the screen keeps the growing composer and symmetric resize.
…adevAI#6751) * fix(desktop): resolve the dev app's workspace title from host.db The dev-only "Superset (<name>)" label, window title, and predev CFBundleName were looked up in v1 local.db, which has had no new workspace rows since May. v2 workspaces and their AI/manual renames live only in ~/.superset/host/<org>/host.db, so every v2-created worktree fell back to its random folder name. Read host.db first (keyed by worktree_path), then the existing v1 lookups. Folder name stays the identity for the data dir, protocol scheme, and Neon branch; only the display title is read live, so a later rename needs no setup re-run. * fix(desktop): tolerate an unreadable host root when resolving the dev title * test(desktop): make the unreadable-host-root case deterministic * test(desktop): drop unused chmodSync import
…adevAI#5337) VS Code–style multiple windows, each bound to its own organization. Open a second window (File → New Window), switch it to a different org, and work on two platforms side by side; switching one window never affects another. Each window's title shows its org, tab/pane layouts are per-window, and open windows restore on relaunch. Also allows the org header through API CORS so a switched window's cloud reads resolve. Verified end-to-end over CDP: per-window org isolation (server-scoped), same-org window independence, and restore-on-relaunch. Co-authored-by: Ash Brener <ashley@starlogik.com>
…her section (ParadevAI#6754) The Cloud header already toggled the shared sidebar-sections-collapse store, but the section never read the flag back, so the chevron turned while the rows stayed put. Gate the rows on it the way Pinned and Sessions do.
…meter (ParadevAI#6756) `provider <> ALL(${PAYLOAD_READBACK_PROVIDERS})` sends the JS array as a single parameter, which Postgres receives as a record and refuses to cast to text[]. Every run since deploy has failed on it — 811 errors in 24h, so the job has never pruned a row. Built as an explicit list instead, the same way the active-subscription statuses are handled in load-custom-session-data. Verified against a copy of production: the statement now plans as an index scan on automation_events_prunable_idx and executes in 50ms. It prunes nothing today, which is correct rather than broken: automation_events was created on 2026-08-15 by migration 0071, so no row is yet older than the 7-day window. It has work from tomorrow. Worth recording why this reached production: I hit the identical array binding failure in a throwaway script earlier the same day and fixed it there, but shipped this route without ever executing it against a database. The partitioning work beside it was validated end to end; this was not. Claude-Session: https://claude.ai/code/session_014D6wif8HDRCXEFf5Ku4w7V
…vAI#6749) ingest.webhook_events is 532GB of a 580GB database, 424GB of it raw provider bodies in TOAST. Nulling them reclaims nothing — dead TOAST only comes back on a rewrite — so retention by UPDATE caps growth but can never shrink the table. Partitioning by day makes retention a DROP, which returns the space immediately. The blocker was the dedup index. A unique index on a partitioned table has to include the partition key, and adding received_at to (provider, event_id) silently disables dedup: a redelivery arrives with a different timestamp, so it no longer conflicts. Verified on a scratch database — the same event delivered twice a day apart lands as two rows. So webhook_events stays unpartitioned and keeps its dedup index untouched; only the bodies move. recordWebhookDelivery writes both tables in one statement, so the hottest write path still costs a single round trip and the two cannot diverge. Its final SELECT reads from the event CTE rather than the payload insert: on a redelivery the body already exists, ON CONFLICT DO NOTHING returns no rows, and returning from that insert would tell the caller a good delivery had failed. This is the expand half only. The payload column stays, because dropping it in the same deploy would fail every insert from instances still running the old build during the rollout — and the existing ingest pruner reads it. New deliveries stop writing it, so it drains to legacy rows that the pruner keeps nulling. Dropping the column, deleting that pruner and retiring its schedule is a follow-up once every writer is on this build. Partition maintenance runs daily rather than once at migration time: it creates 14 days of runway and drops what has aged out. A DEFAULT partition catches anything that slips through, because an insert with no matching partition fails outright, and on this path that means 500s. It must stay empty — the job logs loudly when it is not, since a partition cannot be created over a range DEFAULT already holds rows for. Both points raised by CodeRabbit on the first version of this PR. Claude-Session: https://claude.ai/code/session_014D6wif8HDRCXEFf5Ku4w7V
…ter (ParadevAI#6757) * fix(api): read the DEFAULT partition count before maintenance, not after maintain_webhook_payload_partitions() throws when webhook_payloads_default holds rows for a day it is trying to create — "updated partition constraint for default partition would be violated by some row". Reading the DEFAULT count after the call therefore skipped it in exactly the case it exists to explain, leaving an opaque 500 instead of the row count and the reason. Counted first now, and returned alongside the error so a failing run still says what to drain. Reproduced on a copy of production: drop a near-future partition, let a delivery land in DEFAULT, then run maintenance — it fails at the CREATE. Drain-and-reattach in one transaction recovers it and maintenance runs clean afterwards. Raised by CodeRabbit on ParadevAI#6749; merged before I had read it. Claude-Session: https://claude.ai/code/session_014D6wif8HDRCXEFf5Ku4w7V * fix(api): guard the default-partition count and distinguish unknown from zero The count ran outside the try, so a database failure rejected the request before the structured { error, defaultRows } response could be returned — the same class of gap this PR exists to close, one step earlier. It is now guarded, and an unreadable count reports null rather than 0. Zero reads as "the default partition is empty", which is the opposite of what is known when the query itself failed. Raised by CodeRabbit on this PR. Claude-Session: https://claude.ai/code/session_014D6wif8HDRCXEFf5Ku4w7V
…adevAI#6758) The contract half of the split. Every writer is now on the build that sends bodies to ingest.webhook_payloads, so the column holds only rows received before that deploy and nothing reads it back. The ingest pruner goes with it. Its whole job was nulling this column to cap growth; retention is a DROP of a day partition now, which returns the space instead of leaving dead tuples behind. Its QStash schedule is removed ahead of this merge, so nothing calls a route whose query references a column that is going away. Dropping the column does not shrink anything: verified on a copy of production, the ALTER takes 50ms and the table stays at 532GB, because DROP COLUMN is catalog-only and dead TOAST is only reclaimed by a rewrite. That rewrite is the us-east-1 restore. What this changes is that the 424GB stops being carried forward — the restore no longer has a column to bring across. Also verified on that copy: the dedup upsert on (provider, event_id) still works with the column gone. Claude-Session: https://claude.ai/code/session_014D6wif8HDRCXEFf5Ku4w7V
* feat(marketplace): add Tokyo Night Blackout theme Tokyo Night variant with the main content area and terminal forced to pure black for OLED displays, sidebar left at Tokyo Night's original navy tones. Colors cross-checked against Edmund Yong's community "Tokyo Night Blackout" VS Code theme for the black-background palette. * fix(marketplace): align Tokyo Night Blackout with stock palette Rebuild the theme from Superset's actual tokyo-night.json instead of an external VS Code extension source, so every color matches stock Tokyo Night except the fields the black main-area concept actually requires. Also moves the adaptation/attribution note out of the author field and into description, per review feedback. * fix(marketplace): align listing author with theme JSON Applies cubic's suggestion so the marketplace page shows the same attribution as the downloadable file.
…g link, add release runbook (ParadevAI#6760) * chore(mobile): App Store listing and review notes aligned with ASC, add release runbook store.config.js now mirrors what is live in App Store Connect after the 4.3(a) rejection on build 13: Superset-first title and subtitle, description that names the desktop app and says nothing runs on the phone, promo text, and App Review notes split into headed sections (how to review, payments, sign-in, deletion, code execution, originality). Optional APP_REVIEW_VIDEO_URL appends a walkthrough link. RELEASE.md is the mobile submission runbook: versioning, eas commands, pre-flight checklist, and what to do on rejection or stall. Claude-Session: https://claude.ai/code/session_01SCrpWxwHCWpBAw3CijBvbu * fix(mobile): drop the external billing link from Settings The "Manage plan" alert opened superset.sh/settings/billing in Safari. An in-app link to an external purchase page is a guideline 3.1.1 rejection; the alert now only says where the plan is managed. Claude-Session: https://claude.ai/code/session_01SCrpWxwHCWpBAw3CijBvbu
…radevAI#5799) Tokyo Night inherited its diff-view comment color from ANSI terminal.brightBlack (#414868, ~1.9:1) and used mutedForeground #565f89 (~2.96:1) for muted UI text — both below WCAG AA and near-invisible on the diff view's green/red line tints. Pin the theme's own palette colors (no new colors introduced): - editor.syntax.comment #9aa5ce (variable-token color; ~7:1, ~5:1 on added lines) - ui.mutedForeground #565f89 -> #787c99 (the theme's own foreground/sideBar color; side menu 2.96 -> 4.49:1) terminal.brightBlack is unchanged (correct ANSI dim slot). Relates to ParadevAI#5662. Claude-Session: https://claude.ai/code/session_01H3Jv8HZg2Rjb66jb4bVsZ1 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…aradevAI#6764) * fix(api): keep the webhook body out of a failed ingest write's error Drizzle wraps a failed query in a DrizzleQueryError whose message and own query/params properties are the statement text followed by every bind parameter. recordWebhookDelivery binds the serialized webhook body, so any failure of that write reported the full body of a third party's delivery into the error tracker. Rethrow at the throw site with the operation, provider and the driver's own message, and the driver error as the cause. The statement, the single round trip, the throw-on-failure behaviour and every caller stay as they were. Refs API-1Z Claude-Session: https://claude.ai/code/session_01FnF1gNfEwKxo6G9abY9DPe * test(api): pin the driver error surviving as cause The message assertions alone would still pass if a later change dropped the `cause` link, and that link is what carries the driver's code and stack into the report. Verified load-bearing: removing `{ cause }` from the rethrow fails this test and only this test. Raised by CodeRabbit on ParadevAI#6764. Claude-Session: https://claude.ai/code/session_01Xub6hkHwxp1ZcCYCHZCiCY
…ent Skills guidance (ParadevAI#6762) * docs(skills): make feedback reports concise with a titled, bullet-summary format Title format <Surface>: <symptom or ask>, a 2-4 bullet Summary first, plain-text sections that render in both the private email and GitHub, under-150-word target. Claude-Session: https://claude.ai/code/session_01SudpHaYT4rQ1msxeVpWwC1 * docs(skills): align plugin skills with Agent Skills authoring guidance - split browser into SKILL.md + references/browser-use.md (progressive disclosure) - descriptions lead with user intent and trigger phrases - allowed-tools on every plugin skill, argument-hint where missing - drop monorepo dev-only steps from orchestrate and browser - scripts/sweep.sh (standup) and scripts/audit.sh (10x) for the deterministic sweeps - agents/openai.yaml for every skill, consistent cross-references, no em dashes Claude-Session: https://claude.ai/code/session_01SudpHaYT4rQ1msxeVpWwC1 * test(agent-setup): validate bundled skills against the spec; keep scripts executable when provisioned Claude-Session: https://claude.ai/code/session_01SudpHaYT4rQ1msxeVpWwC1 * fix(skills): address review: report type, exited terminals, mktemp, file-captured CLI output, stricter skill test Claude-Session: https://claude.ai/code/session_01SudpHaYT4rQ1msxeVpWwC1 * style(agent-setup): format bundled-skills test Claude-Session: https://claude.ai/code/session_01SudpHaYT4rQ1msxeVpWwC1
…AI#6761) * feat(mobile): native composer skeleton, on Expo SDK 57 and an iOS 26 floor The RN↔SwiftUI seam is what makes today's `GlassComposer` painful, not SwiftUI: `Host matchContents` under-reports the settled glass, positional child identity means no row may ever unmount without the field losing first responder, and two animation systems have to be reconciled by hand. This starts replacing it with a single native view tree. Prep, because the composer's material needs it: - Expo 56 → 57.0.15 (RN 0.86.2). Also fixes the Hermes V1 memory regression we were carrying with reanimated 4.3.1 / worklets 0.8.3. - iOS deployment floor → 26.0, so `glassEffect` is unguarded and there is one visual language instead of a glass path plus a solid fallback. - Retire the expo-router touch-cancel patch; the fix is upstream in 57.0.15. - `expo install --fix` silently downgraded `@sentry/react-native` 8.23.0 → 7.11.0 and `@shopify/flash-list` 2.3.2 → 2.0.2 (the latter removed `stickyHeaderConfig`). Both restored, and the 32 packages it moved to `~` ranges re-pinned exact. metro.config.js: uniwind and worklets Bundle Mode both alias `react-native` and neither knows about the other, so the shim's own import landed back in uniwind and startup died in infinite `get NativeModules` recursion. Resolve the shim's import to the real module. modules/composer: - A zero-size `ExpoView` attaches the composer as a child view controller of the screen's own VC, so SwiftUI gets a real full-screen safe area. Keyboard avoidance then costs zero code — no `keyboardLayoutGuide`, no notifications — including a field growing to 12 lines with the keyboard up. - Collapsed shows a read-only draft preview, expanded the real editor; they cross-fade rather than one field relocating, which is what keeps the text from sliding and removes any first-responder to lose. - Controls sit in the bottom row of a bottom-anchored stack, so they hold position while everything above grows and shrinks. - Buttons are on solid fills, not `.buttonStyle(.glass)`: Apple's guidance is one glass sheet per view, and nesting leaks the press state past the container. - `backdrop: dim | passthrough` — dim owns the screen and dismisses on an outside tap; passthrough leaves content behind live so a transcript can be scrolled while typing. Verified on iPhone 17 Pro / iOS 26.5 with 10 Maestro flows: touch passthrough and blocking, expand, drag-to-dismiss, tap-to-dismiss, overlay lifecycle across navigation, button events, the submit round-trip, sheet dismissal restoring the keyboard, and scroll-through. `composer-preview` is unlinked dev scaffolding, deleted at cutover. Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie * feat(mobile): attachment carousel, and stop `+` opening the keyboard behind the sheet Carousel, per frames 6/7/9/10/11. The tray stays in React Native — it is shared with the attachments sheet — so the composer takes a description of it as data and reports removals and taps back out. Expanded renders a full-bleed horizontal strip: the scroll view spans the card's width while its content carries the inset, so a scrolled item runs to the card's edge. Collapsed keeps one mini thumbnail plus a `+N` badge, so the pill's width is independent of the count. Send now appears for attachments alone, not just a draft (frame 14). Fixes `+` from the collapsed pill raising the keyboard behind the sheet. Presenting resigns first responder and collapses the composer, and the caller was restoring focus unconditionally — so a composer the user had left closed came back open, briefly showing both. The composer now reports `onExpandedChange` and the caller restores only what was actually open. `useAttachmentsSheet` grew an `onClosed` callback for this, which also required surfacing the sheet's existing `onDismiss` through `presentAttachmentsSheet`'s handler type; it was being swallowed internally. `@Field` wrappers block Equatable synthesis, so ComposerAttachment writes `==`. 11 Maestro flows pass, including the new one asserting `+` from collapsed leaves the composer closed. Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie * feat(mobile): model picker with agent icon, and the header chips row Frame 4's header row (`superset main ⌄` · `Cloud ⌄`) and the model picker, both data-driven. The picker shows the selected agent's brand mark beside its name, resolved the way `useAgentIconUri` already does it — SwiftUI cannot read Metro asset references, so React Native resolves the bundled PNG to a local file URI first. Both report a press rather than presenting anything themselves. The first cut made the model picker a native `Menu`, which was wrong: the real pickers are `formSheet` routes wrapping a whole Stack, and their lists are searchable — branches come back 50 at a time from `searchBranches`. A menu cannot search, and holding those lists in the composer would mean shipping the data across the boundary for no reason. So the composer displays the selection and React Native keeps presenting the sheets it already has, exactly like `+` and the attachments sheet. `selectedModel` omitted hides the picker, and an empty `headerChips` hides the row — which is how the session surface (frame 13) and the terminal differ from home, without either needing its own component. Preview uses mock agents until cutover; the shape is already the shape the real data takes, so only the source changes. 12 Maestro flows pass. Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie * feat(mobile): match carousel geometry to the reference, animate send, add a sending state Carousel, measured off frames 6 and 9 rather than estimated: - Thumbnails were 56pt against the reference's ~80pt, and proportionally rounder (radius/size 0.18 vs 0.13). - The remove badge hung *outside* the corner as a bare glyph. In the reference it is a dark translucent circle sitting fully inside the thumbnail, inset by roughly its own radius, over the image. - Collapsed `+N` was a corner chip on a dark capsule; the reference centres plain white text on the thumbnail. Mini thumbnail 30pt → 34pt. - Frame 6 leaves air between the strip and the first line of text. Typing now animates: send fades in and the mic slides left to make room. Both come out of one animation on the control row — the transition fades send, the HStack's own layout carries the mic, so neither needs to know about the other. New `isSending` prop for the in-flight state: send turns grey and swaps its arrow for a turning refresh glyph, and the mic steps aside so the spinner is the only thing moving. The caller owns the flag because only it knows when delivery finished — the same reason it owns clearing. Wires the model picker and header chips to the real pickers. An earlier commit claimed this; it was not true — the preview cycled a mock list. `onModelPress` now pushes `new-session/agent`, and the chips push `project` / `branch`. From the preview route they present full-screen rather than as sheets, because `presentation: "formSheet"` is declared in the `(home)` layout; at cutover the composer lives inside that group and gets the sheet chrome. 12 Maestro flows pass. Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie * feat(mobile): put the native composer on the home screen Cuts `NewChatWidget` over from `GlassComposer` to the native module. Everything the old composer took as React children is now data: `header` and `toolbarLeading` become `headerChips` and `selectedModel`, both carrying the same real project, branch and agent they always did. What went away with it: the `KeyboardAvoidingView`, the absolute-fill dimming backdrop and its dismiss `Pressable`, the `active` state that drove them, and the safe-area padding maths. The composer owns keyboard tracking, dimming and dismissal now, so the widget is 100 lines shorter and holds no layout at all. The `above` slot is deleted rather than ported. It was a `ReactNode`, nothing passes it, and the boundary is data-only by design — the moment a node crosses it the seam artifacts come back. Carousel geometry, measured off frames 6 and 9 rather than estimated: corners less round (radius/size 0.14 → 0.11), and the remove badge is solid rather than translucent. Thumbnails get a gradient hairline and a soft shadow to lift them off the glass — there is no dedicated iOS API for that, and a *flat* stroke is the wrong answer because it disappears wherever the content behind is light. Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie * feat(mobile): dictation, and fade expanded content out early on collapse Dictation. The state machine stays in React Native — `useVoiceDictation` owns the recognizer, the permission prompt and the append semantics, and it is already proven — so the composer renders the state and reports the two presses that drive it. Recording swaps the mic for a pill (stop square, elapsed clock, level meter); finalizing shows the spinner; send stays out of the row while either runs, so nothing competes with the pill. The clock is `Text(timerInterval:)`, which iOS redraws itself. The old `RecordingPill` ticked a `@State` date once a second purely to redraw one label. Dictation appends rather than replaces, and the append happens natively via a new `appendDraft`: the draft lives in the composer, so having React Native do the join would mean mirroring every keystroke back across the bridge just to read the base at settle time. Collapse: header chips, carousel and editor now leave on their own fast curve rather than the card's. Fading them over the full 0.3s left them legible almost all the way down, so they read as being inside the collapsed pill. Also from review: the composer is 12pt from the screen edge rather than 16pt (measured off frame 1), and the send arrow is the theme's ink rather than pure black — against a near-black background pure black reads as a hole punched through the button instead of a mark drawn on it. Growth is bounded by `lineLimit` alone. A `.frame(maxHeight:)` is the wrong tool and was briefly the wrong answer here: a max height makes the field expand to fill it rather than hug its content, which left an empty card the size of the cap. 11 Maestro flows pass. Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie * feat(mobile): native dictation, image viewer, and file previews in the composer Dictation runs on this side of the bridge now. It began as a prop mirror of `useVoiceDictation`, and the level meter is what settled it: the recogniser's volume was crossing the bridge ten times a second for the length of a recording purely to animate five dots. `expo-speech-recognition` is itself a wrapper over `SFSpeechRecognizer`, so owning it here is less indirection rather than more. The state machine is carried over intact — continuous with final results only, a 15s finalize backstop, the recogniser's own task end as authoritative, append-never-replace — and so is the level curve, peak amplitude with a -60 dB floor and a 1.5x gain. An RMS curve was tried first and pins the bars near full, because RMS runs 10-15 dB under peak for speech. Starting a recording session costs the app first responder, which the composer read as a dismissal and closed underneath its own recording pill. It now holds itself open for anything it put on screen, and `preparing` exists so it knows dictation has begun from the instant of the press rather than after an async permission round trip. Tapping an image opens it full screen with pinch, pan and double-tap zoom on a `UIScrollView` — SwiftUI has no zoom API, and hand-rolled magnification misses rubber-banding and centring. The composer presents it rather than reporting the tap out: it already holds the URI, and a React Native screen over a SwiftUI first responder is the arrangement this rewrite exists to remove. `onAttachmentPress` now fires only for non-images. File attachments get frame 10's wider card instead of an 80pt square, with a real `QLThumbnailGenerator` preview covering its tile. Every document draws the same glyph, so without a preview and a name the tray is a row of identical grey squares. The card also animates its own growth. iOS does not do this for us: SwiftUI resizes the instant content changes unless the change sits inside a transaction. The attachment strip is keyed with `.animation(_:value:)`; the editor's growth had to be wrapped in `withAnimation` inside its binding's setter, because a vertical `TextField` resizes through its UIKit text layout and lands outside the transaction an ancestor opens. Measured on 60fps captures: four lines to five snapped in a single frame before, and eases over six now. Smaller, all measured off the reference frames rather than eyeballed: busy states use the stock `ProgressView` rather than a hand-rolled spinning arrow; send drops to the ordinary control fill while in flight; the project chip leads with its logo and reads at full foreground while the branch stays a step back; the remove badge gets the cards' shadow. Verified on an iPhone 17 Pro / iOS 26.5 simulator against production: file and photo attachment round trips, the image viewer, the growth curves. Dictation still owes a real-device pass — the simulator has no speech recognition, so the recogniser errors within milliseconds of `engine.start()`. Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie * fix(mobile): drive every composer animation from the mutation, not the view `.animation(_:value:)` animates only the subtree it is attached to. The composer's surface is positioned by its parent, so a modifier on the surface never reached the frame change that matters: the card snapped to its new height while the rows animated to new positions inside it, which is why removing an attachment slid the input and buttons around. The same split put the control row's horizontal layout on its own curve while the card's transaction drove the vertical one — one control on two curves is a control moving along an arc. So there is now one rule, and no `.animation(_:value:)` left on the surface or the row: whoever makes the change opens the transaction. Three named curves say what kind of change it was — `growth` for content arriving or leaving, `typingGrowth` for text wrapping, `controlSwap` for controls trading places — and every mutation site uses one: the module's prop setters, `setDraft`, and `ComposerDictation.setState`. Also gives the remove badge a real hit target. It keeps its 17pt mark, which is what the reference draws, but carries 9pt of transparent padding with the outer inset reduced to match, so the circle stays where frames 6 and 9 put it. 17pt is under half Apple's 44pt minimum; aiming at it repeatedly opened the image viewer instead of removing the attachment. Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie * fix(mobile): fade send in place instead of letting it ride the card's geometry The control row is a `.geometryGroup()` now. Without it the row's children inherit the card's *interpolating* geometry while it resizes, so a control that arrives mid-resize — send, the moment a draft exists — animates in from where the row used to be rather than fading in where it belongs. Measured on 60fps captures with the keyboard settled: send holds x=1097.5, y=1520.8 from its first visible frame and only its opacity ramps, while the mic slides 1015 to 977 with its y fixed. Pure fade and pure translation, which is the behaviour this was always meant to have. Worth recording for the next motion bug: while the keyboard is rising the whole card translates about 169pt at constant height, and anything appearing during that ride looks like it is sliding. Two reports chased here turned out to be that rather than the composer. Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie * fix(mobile): guard dictation against a busy microphone, drop the preview harness `installTap` raises an Objective-C exception — not a Swift error, so there is nothing to catch — when handed a degenerate format, which is what the input node reports while something else holds the microphone: a phone call, another recording app. Checking the format first turns a crash into the ordinary failure path, where the composer settles back to idle and surfaces the error. Worth closing before shipping a path a simulator cannot execute. Also deletes the `composer-preview` harness. It existed to exercise states the home screen could not reach on demand, and every one of them is now reachable there. Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie * refactor(mobile): rehome the attachments sheet hook, stop inventing a branch name `useAttachmentsSheet` lived inside the `GlassComposer` folder while the native composer imported it from there, so deleting the old composer would have taken the new one's attachment sheet with it. Both callers now reach it at `screens/(authenticated)/hooks/`, which is their highest shared parent. The branch chip fell back to the literal string "default" while the branch list was loading. That reads as a branch name and is not one — it also reached the create interstitial's subtitle as `project · default`. The label is null until there is a real branch to show: the chip is absent rather than wrong, and the subtitle drops the separator. Nothing changes about the create itself, which sends `baseBranch` and lets the host pick its own default when that is absent. Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie * fix(mobile): use the SDK 57 Babel preset, and drop the unclearable target chip `babel.config.js` resolves the bare `babel-preset-expo`, which reaches the app's own direct dependency — still pinned at 56.0.11 — rather than the 57.0.7 that expo@57.0.15 brings. So every bundle since the SDK bump was transformed by the SDK 56 preset against React Native 0.86.2, with `@react-native/babel-plugin-codegen` 0.85.3 and a Hermes parser three minors behind. It built and ran, which is why this went unnoticed. Pinned to 57.0.7; the lockfile loses the duplicate entry and everything now shares expo's own copy. Rebundled with `--clear` and booted to confirm. `fixedTarget` pins the composer to one workspace and is not the user's to clear, but it still rendered a "New agent in …" chip whose press only clears `storeTarget` — so the chip stayed put. A fixed target now gets no chips at all, which is what the prop documents. Latent today: nothing passes `fixedTarget`. Also moves `Composer` out of the package barrel into its own folder, per the repo's one-folder-per-component rule. Found by CodeRabbit on ParadevAI#6761. Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie * chore: exempt the Expo SDK 57 pins from the release-age gate Expo 57 landed inside the 3-day `minimumReleaseAge` window, so a fresh `bun install` could not resolve it — eleven packages failed at once. CI never saw this because it installs frozen from the lockfile, which is exactly the shape of problem that only bites whoever clones next. `minimumReleaseAgeExcludes` covers just those pins. They are exact versions, so the list can only ever admit the ones we already chose. Verified with a full `bun install --force` and no bypass flag: it resolves, and the lockfile it produces is byte-identical to the committed one. The list can go once the pins age past the window. Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie * Revert "move Composer into its own folder": it crashes tsc TypeScript 6.0.3 panics with `Debug Failure` inside `resolveExternalModule` while checking an import, once `@superset/composer` resolves through `src/index.ts` -> `src/Composer/index.ts` -> `src/Composer/Composer.tsx`. Bisected: the layout is the only difference, and pointing the outer barrel straight at the file does not help — the crash needs the folder gone. The restructure was a file-convention nicety suggested in review. It is not worth crashing the typechecker for, so the module keeps its single `src/index.tsx` entry. Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie
…ons test (ParadevAI#6770) apps/api had four test files and no `test` script, so `turbo test` skipped the package entirely and CI had never run any of them. One had been failing on main unnoticed as a result. The failure was mock drift, not a leak across files: the test mocked `@superset/db/schema` with only `userIdentities`, while the route imports `integrationConnections` from it as well, so the module failed to load at all (`SyntaxError: Export named 'integrationConnections' not found`). The mock also lacked `userIdentities.organizationId`, which the route uses. Dropping the schema mock is the fix rather than extending it: the schema module is pure table definitions with no connection or env access, and the db client is already mocked separately, so there was nothing for that mock to protect against and it could only drift again. That exposed the same drift one layer down — the db mock had no `query.integrationConnections`, which the route reads to resolve the Slack workspace to an organization before it looks at any action. Returning a connection is what lets the assertions reach the action handling; without it the route returns early and the tests would pass for the wrong reason. Verification: - `cd apps/api && bun run test` -> 21 pass, 0 fail across 4 files (was: module would not load, 1 fail + 1 error). - `bunx turbo test --filter=@superset/api` -> runs the package, 21 pass. Turbo skipped it before. The "no output files" warning is pre-existing and emitted for every package with no coverage output, confirmed against port-scanner. - Checked the repaired tests are not vacuous: removing the value-type check from isSlackInteractionAction fails "ignores model select actions with malformed selected option values", and only that test. - `cd apps/api && bun run typecheck` clean; `bunx biome check` on both files clean. This also puts ParadevAI#6764's recordWebhookDelivery leak test under CI, which it was not before. Claude-Session: https://claude.ai/code/session_01Xub6hkHwxp1ZcCYCHZCiCY
…aradevAI#6771) `git/removeWorktree` blows its two-minute budget in the field (HOST-SERVICE-17, HOST-SERVICE-47) and the only thing the report says is that 120000ms elapsed. The task's three steps — resolving the worktree path through a blocking realpathSync, the `worktree remove` subprocess, and the `worktree list` read after it — stall for entirely unrelated reasons, and nothing distinguished them. The budget is a timer in the parent while the steps run in the worker, so at the moment the parent gives up it knows only the elapsed time, and it retires the worker immediately afterwards. The worker therefore announces each step as it starts; the parent keeps the last announcement and appends ` in phase "<phase>"` to the timeout error, which is interpolated verbatim into the TRPCError that reaches Sentry. A task that reports no phase produces the same message as before. This changes nothing else: not the budget, not retry behaviour, not what the user sees, not the deletion logic. We still do not know why it hangs — that is what this is for. Refs HOST-SERVICE-47 Refs HOST-SERVICE-17 Claude-Session: https://claude.ai/code/session_01BrNKdKTC54LnA9jdpDDtA8
* feat(ingest): bound row growth on both event logs Bodies are bounded — webhook bodies by dropping day partitions, automation payloads by the pruner — but nothing ever removed the rows. webhook_events is at 105.7M and takes 1.16M/day, automation_events another 758K/day, both with no ceiling. Deleting rather than partitioning, because the dedup indexes have to stay where they are: a unique index on a partitioned table must contain the partition key, and adding received_at to (provider, event_id) would mean a redelivery no longer conflicts and dedup silently stops working. Deletion is cheap here in a way it would not have been a week ago. With the bodies moved out these rows are ~350 bytes and touch no TOAST, so a batch leaves very little for autovacuum. Measured on a copy of production: 5,000 rows in 403ms, planning as an index scan then a TID scan. Retention is 30 days, set by idempotency rather than storage — the unique indexes are what stop a redelivered event running twice, and a row that no longer exists cannot dedupe. Providers redeliver within hours to days, so thirty is well past any real replay window. One constant to change. automation_events gets a plain received_at index; its three existing ones are either partial or lead with organization_id, so none can serve an age-range scan. The build takes ~20s on 4.5M rows and holds a write lock for that time. Paced at 50k rows per table per run on a 5-minute schedule, the 65.2M-row backlog drains in ~4.5 days and then holds steady. webhook_payloads needs no equivalent: its partitions are dropped at seven days, so a body is always gone well before the event row pointing at it. Claude-Session: https://claude.ai/code/session_014D6wif8HDRCXEFf5Ku4w7V * feat(ingest): bound row growth on webhook_events webhook_events is at 105.7M rows taking 1.16M/day with nothing deleting them. Bodies were bounded when they moved to day partitions; the rows were not. Deleting rather than partitioning, because the dedup index has to stay on (provider, event_id): a unique index on a partitioned table must contain the partition key, and adding received_at would mean a redelivery no longer conflicts and dedup silently stops working. Deletion is cheap now in a way it would not have been a week ago. With the bodies moved out these rows are ~350 bytes and touch no TOAST, so a batch leaves very little for autovacuum. Measured on a copy of production: 5,000 rows in 403ms, planning as an index scan then a TID scan, against the webhook_events_received_at_idx that already exists. Paced at 50k per run on a 5-minute schedule the 65.2M-row backlog drains in ~4.5 days. Retention is 30 days, set by idempotency rather than storage — the unique index is what stops a redelivered event running twice, and a row that no longer exists cannot dedupe. automation_events is left out on purpose. Bounding it needs two index builds that take write locks on live ingest tables, and it cannot have a row older than thirty days until mid-September, by which point the us-east-1 restore has rebuilt both tables with those indexes created on the way in. Adding it here would buy nothing and cost two locks. The per-target time budget is sliced rather than shared even at one target, so the second cannot be starved when it joins. CodeRabbit raised all three: the missing automation_runs.event_id index, the shared deadline, and the blocking index build. Dropping that half of the change resolves the first and third outright. Claude-Session: https://claude.ai/code/session_014D6wif8HDRCXEFf5Ku4w7V
…e's chrome (ParadevAI#6773) * fix(mobile): size the composer chrome to the reference and tighten the header gap Measured a prod screenshot against frame 4, both normalised to 3x on a 440pt screen and anchored to each card's own top edge: gap, header ink -> first draft ink ours 30.3pt mock 19.0pt header text ink height ours 15.3pt mock 12.7pt Two causes. The chips and the model picker inherited the 17pt default body because nothing set a font, where the reference's chrome is ~14pt; and the header carried an 8pt bottom inset on top of that. The editor was already right — its 17pt matches the mock — so only the chrome moved. Now 18.3pt and 12.3pt against those same references. The remaining inset is 5pt rather than 8: dropping the chrome to 14pt took ~11pt out of the line box on its own, so the padding had to come down with it rather than stay put. Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie * docs(mobile): note the module-scheme build loop Rebuilding the whole app to change one Swift file costs minutes a cycle; CocoaPods generates a scheme per pod and building that one is ~6s. Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie * feat(mobile): put the native composer on the terminal screen Two surface differences from home, both props rather than children. The backdrop is `passthrough`: the transcript stays live and scrollable while the keyboard is up, because reading scrollback while typing the next command is the point of this screen. Home keeps `dim`, where the composer owns the screen and the outside tap dismisses it. The quick keys move into the composer's own view tree. They have to: as React Native siblings the gap to the pill was a hardcoded guess at a height the host under-reported, so it drifted whenever the pill grew and animated on its own curve — the previous `QuickKeysRow` says exactly this in its own doc comment, which is why it had already been pushed inside the SwiftUI host once. Against an overlay React Native cannot measure at all, so a sibling was never an option. They cross as data — `{ id, label?, symbol? }`. The bytes each key writes into the PTY, and whether it submits instead, stay with the terminal that owns them; the composer draws a mark and reports an id. Select mode then needs no native code at all, just a different array. Styled with the system's own `.glass` button inside a `GlassEffectContainer` rather than a hand-rolled fill. The earlier lesson about not nesting glass applies to controls sitting *on* the card's sheet; this strip floats over the terminal as a surface of its own, which is the case the style exists for — and it brings press feedback, hit slop, Increase Contrast and Reduce Transparency that a fixed opacity has to fake. Also adds `showAttachments` (a plain shell would try to execute an attachment path) and `autocapitalization` (a shell command is not a sentence). Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie * fix(mobile): let the terminal inset for the composer, and drop the glass chips Three things the terminal surfaced that the home screen never could. The composer draws in an overlay and takes no React Native layout space, so the screen's `onLayout` measured roughly nothing and the terminal's own prompt sat behind the card. The composer now reports the room it occupies — card, quick keys, and the gaps — and the screen insets by that. It reports its *size*, not its position: the keyboard moves the cluster without resizing it, so the value only changes when the composer genuinely grows, and the keyboard stays the screen's business, where it already arrives with a duration and a curve worth animating to. The pull-requests button moves above the composer for the same reason; it used to share a wrapper with a composer that no longer occupies it. And the quick keys go back to `.bordered`. Glass is a material for sampling rich content behind it, and the content here is a mostly-black scrollback — it read as a smear. The bordered chip is what the row had before and still brings the system's press feedback, hit slop and contrast handling. Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie * fix(mobile): hide the model picker when there is nothing selected Found by driving a real terminal session: with no agent to pick, the picker still drew its chevron beside an empty label — an orphan control in the corner of the card pointing at a menu that does not exist. The home surface always has a model, so it never showed there. Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie * fix(mobile): gate terminal attachments on the session, not just the button The attachment tray is shared across tabs, so files attached in an agent session are still there after switching to a plain shell. `allowAttachments` hid the `+` button but not the submit path, so those paths would still be appended to the message — and a plain shell tries to *execute* them, which is the exact failure the flag exists to prevent. Also stops the pull-requests button floating: it positions itself above the composer's reported height, which goes stale the moment the composer is hidden. Found by CodeRabbit on ParadevAI#6773. Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie
) `ResizablePanel` reads `defaultSize` only when the group mounts, so equalizing a v2 tab updated the layout in the store while the panels stayed where they were last dragged — the ⌘⇧0 hotkey and the "Equalize Pane Splits" context-menu item both looked like no-ops. Push the store's split percentage through the group's imperative handle whenever it changes. Claude-Session: https://claude.ai/code/session_01Td2NUVeMANb1qfap6xtzxt
…aradevAI#6778) * feat(mobile): paste images and files into the composer, delete GlassComposer **Paste**, without touching the editor. `ComposerRootView` is byte-identical to what shipped. SwiftUI has no paste hook on iOS: `onPasteCommand` and `pasteDestination` are both `@available(iOS, unavailable)`, and `PasteButton` is a button rather than the edit menu. UIKit's route is a paste configuration, and the responder chain is what makes it cheap — the text field keeps taking strings exactly as it does today, and anything it cannot represent walks up to `ComposerPassthroughView`, which the composer already owns for hit-testing. Thirty lines on a view that already exists, and no editor of our own to maintain. Providers resolve to files on disk because the tray takes URIs, the same shape the pickers produce: a file copied in Files arrives as a URL and is copied out of its temporary home, an image copied from Photos or a browser arrives as bytes and keeps its original encoding rather than being re-rendered. **Deletion.** `GlassComposer` has no callers left, so it and its voice stack go, and `expo-speech-recognition` with them — 13 files. One trap there worth naming: that package's config plugin was what injected `NSSpeechRecognitionUsageDescription`. Removing the plugin without replacing the key would have left native dictation terminating the app on its first authorization request. The string now lives in `ios.infoPlist` directly, and a prebuild confirms all four usage descriptions still land in the generated Info.plist. The microphone one was never at risk — `expo-image-picker` supplies it. Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie * fix(mobile): clear the attachment tray on send, not just the draft Attachments survived every successful send and went out again with the next message. The native composer's `clear()` reaches its own text and nothing else — the tray belongs to React Native. The React Native form it replaced had a `clearAll` that cleared both, and cutting the composer over to a direct `onSubmit` callback left only half of that behind. Shipping since the home screen moved over. Both surfaces now clear the two together, and still only on success: a failed send keeps the draft, so it has to keep the attachments too or the retry sends something different from what failed. Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie * chore(mobile): drop expo-speech-recognition from the lockfile Deleting GlassComposer took the last consumer with it, but the lockfile still carried the package. Also ignores the pasted-attachment scratch directory the workspace writes into. Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie * feat(mobile): keep a composer draft per surface across navigation Leaving the home screen or a workspace threw away whatever was typed there. Text and attachments now survive navigation, scoped per surface — the home screen, and one draft per workspace — and are restored when you come back. Where the state lives was the real question. The tray was already React Native's and cannot move: the pickers, the attachments sheet, paste and the terminal's uploader all mutate it, and the composer only renders a mirror. Putting the text in Swift beside it would make a draft two halves in two languages with two lifetimes to keep in sync, so both halves live in React Native now, in one in-memory store keyed by surface. The composer stays uncontrolled, which is what makes this safe. It reports `onChangeText` outward the way it already reports `onHeightChange`, and takes its text back exactly once, through `restoreDraft` at mount. There is deliberately no `value` prop: a controlled field resizes outside the transaction that reveals send, which is the split-brain the motion rule exists to prevent. Nothing subscribes to the text either — a caller reads it once to hand it back — so typing still causes no React Native render at all. Not persisted across launches. Attachment URIs point into the app's cache, which the OS may evict between launches; within one launch it cannot, and that is exactly this store's lifetime. Two things fall out of scoping it. The single app-wide tray is gone, so an image attached on the home screen is no longer sitting in every workspace — `PromptInputProvider` was only there to hold it and is no longer mounted. And a plain shell now hides the tray rather than showing chips its submit will silently drop, since a workspace's draft is shared across its terminals. Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie * fix(mobile): make the saved draft actually reach the composer Two bugs, both only visible by driving the simulator — the code read fine. The outward event never arrived. Named `onChangeText`, it collided with React Native's own TextInput event convention and was swallowed before reaching JS, so nothing was ever saved and every restore read an empty store. Renamed to `onDraftChange`, which is also the more accurate name: this reports a draft, not a text field's value. Restoring through the ref raced the view's own creation. At mount the call landed before there was anything to receive it — a 400ms delay made it work, which is the shape of a race, not a fix. The saved draft is an `initialDraft` prop now, delivered as part of setting the view up rather than chasing it, and applied once so later deliveries cannot clobber live typing. `restoreDraft` is gone from the ref; nothing needs it. Verified on the simulator: a draft typed on the home screen survives opening a workspace and coming back; a draft typed in a workspace comes back on return; neither leaks into the other; an image attached on the home screen no longer shows up in a workspace tray; and a send clears text and tray together. Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie * fix(mobile): don't discard a failed paste copy, or a tray a shell never sent Both from CodeRabbit on ParadevAI#6778, both real. A failed `copyItem` was swallowed by `try?` and the paste still resolved, handing back an item pointing at a file that was never written — the tray would only find out at upload or preview. `loadImage` ten lines below already guards its write the same way; now `loadFile` does too. And a plain shell was deleting attachments it never sent. Its submit omits them by design, but the clear afterwards wiped the tray anyway — and because a draft belongs to the workspace rather than to one terminal, an image attached in an agent session vanished when a command ran in a shell beside it. The tray is now cleared only when the send could actually carry it; the text always is. Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie
…evAI#6777) Open a file editor next to another pane and the divider beside it grew to 3px. The pane box drew a 2px border that turned from transparent to border-coloured when the pane took focus, and that border sat directly against the 1px line its neighbour already drew: the split ResizableHandle on one side, the right sidebar's ResizablePanel border on the other. The pane no longer draws that border. PaneHeader already dims to 60% opacity on the panes that are not focused, so the focused pane is still readable, and every seam is now a single 1px line. The sidebar's top strip also stepped down from the pane tab bar. The PR action header was h-12 against the tab bar's h-10, so the two horizontal borders landed 9px apart at the divider. The header is now h-10, and the sidebar tab row is pulled up one pixel so its top border shares a screen row with the tab bar's bottom border. Verified against a running dev build over CDP: both border bands report [39, 40], and the two seams measure 1px where they measured 3px before. Claude-Session: https://claude.ai/code/session_01AWpYhSNQVyCQWUfuJ4X5bP
…rminal tab (ParadevAI#6786) * fix(mobile): refetch host agent lists on focus and land on the new terminal tab React Query never saw app focus on native — focusManager was unwired, so no query refetched on foregrounding and the composer's agent list stayed frozen from app launch. Wire focusManager to AppState, and give the three host agent-config queries one shared hook that refetches unconditionally on focus, since agent edits happen on the desktop while this app is backgrounded. The + sheet's dismissTo lands with the new session in ?tab= before the terminals query has heard of it, and the pin-active effect re-asserted the old tab in the same commit the fresh row arrived — the new session never activated. Merge adoption and pin into one effect that adopts a ?tab= once its row arrives, so the fresh terminal wins over the pin exactly once. * fix(mobile): a manual tab pick consumes a pending ?tab= adoption
… a buffer (ParadevAI#6788) decryptCookieValue's "v10" scheme check ran before its own try/catch, so a cookie row whose encrypted_value came back as a string threw `encryptedValue.subarray is not a function` out of mapCookieRow, killed the read loop, and failed the whole import. Move the check inside the guard so such a row is skipped like any other undecryptable one. Refs DESKTOP-11J
… fs handler (ParadevAI#6789) The rename branch of the Files tab's fs-event handler looked up the renamed folder's handle with an unguarded `model.getItem`. Pierre's `getItem` is typed to return null for a path it doesn't hold, but it throws when the lookup walks through a node with no child index, so the throw escaped the workspace event socket callback and aborted the rest of that watcher batch. Contain the lookup: a path the model can't resolve now yields no handle, which is the same answer as "not expanded". Refs DESKTOP-11E
…#6790) proper-lockfile keeps an acquired lock fresh on a timer. When another desktop main process reclaims the entry because our refresh fell behind, that timer's stat returns ENOENT and the library's default onCompromised handler rethrows from inside its own setTimeout, where no caller can catch it. Supply a handler so the lost lock is recorded instead. The same event marks the lock released, so the finally-block release() rejects with ERELEASED and replaces the operation's result -- reporting a token that was written as a sign-in that failed to save. Releasing is cleanup and says nothing about whether the write landed, so it no longer overrides the operation's own success or failure. Acquisition failures and write failures still reject, so a token that was not persisted can never look persisted. Refs DESKTOP-11M Claude-Session: https://claude.ai/code/session_01PXMCuUAHrKLAhRWtPF1o7V
…adevAI#6793) `normalizeEvent` stats every non-delete event to decide `isDirectory`. When that stat fails — the ordinary case for a rename that moved the path on again — it fell back to `state.directoryPaths.has(path)`, which answers `false` for a path the watcher has never seen. So an uninspectable directory was emitted as a positive "this is a file". Report no type at all when there is nothing to report. A remembered path still answers from the record (directory or file); only a path the watcher has never seen now comes back with `isDirectory` absent, which `FsWatchEvent` has always declared optional. That hands the Files tab's rename branch the `undefined` its existing `event.isDirectory ?? oldKey?.endsWith("/")` fallback was written for, so a renamed folder keeps the type the tree already holds instead of entering as a file node. Refs DESKTOP-11E Claude-Session: https://claude.ai/code/session_01LZ4rtC7vJCrUYNTaUxqdR3
…write (ParadevAI#6792) * test(desktop): pin that a reclaimed auth-token lock cannot crash the write Nothing covered the path ParadevAI#6790 fixed. Deleting the onCompromised handler from withAuthLock reintroduced a fatal, uncatchable throw on the sign-in token write and the whole suite stayed green. The refresh proper-lockfile arms after handing us the lock only fires on a timer derived from `stale` -- five seconds at the current setting -- which is too long to wait for in a suite that runs in under a second. Intercept that one scheduling call instead: remove the lock entry the way a second desktop process reclaiming it would, then run the library's own refresh callback. Its stat, its ENOENT handling, its compromise bookkeeping and its onCompromised dispatch are all the real thing; only the five-second delay is skipped, and the library is never mocked. Refs DESKTOP-11M Claude-Session: https://claude.ai/code/session_01H1nPckannUVVytgNdnd3YG * test(desktop): wait for the reported lock loss instead of racing it The compromise is reported from proper-lockfile's own stat callback, which can land after saveToken resolves. Reading the count straight after the write made that assertion pass or fail on scheduling; await the report instead. A loss that is never reported now hangs to the test timeout, which is the failure we want, and removing onCompromised still fails fast with the library's ECOMPROMISED throw. Claude-Session: https://claude.ai/code/session_01YXpYt5vhSMHdqFPUymBdc6 --------- Co-authored-by: Satya Patel <satya@superset.sh>
…inbound Linear events (ParadevAI#6799) * fix(linear): make the task mirror reject stale inbound events Groundwork for putting a queue in front of this endpoint, not a fix for a failure anyone has reported. A write we push to Linear returns to us as a webhook, and the mirror applies it unconditionally. Today that is close to harmless: the echo only overwrites if a second edit lands in the second or two before it arrives, and it is largely self-correcting, because that second edit pushes too and its own echo repairs the damage moments later. The case that does not self-correct — two echoes arriving out of order, the stale one landing last — is rare enough over direct delivery that there is no evidence of it happening. Both of those protections are properties of low, ordered latency, and a gateway removes them. Queue depth widens the window from seconds to however far behind the consumer is, minutes on a retry, and at-least-once delivery makes reordering routine rather than exceptional. The guard has to exist before deliveries can be delayed and reordered, not after. Records Linear's own updatedAt on every outbound write and refuses any inbound event that is not strictly newer, which covers the echo and the reordering with one comparison. Checked ahead of the branchName fetch so a suppressed event costs no Linear API call, and again in the upsert's setWhere, because that read can go stale before the write lands and two deliveries for one issue can race. Compared column-to-column against excluded rather than a bound parameter, so no second timestamp conversion can disagree with the first. Rows predating the column have a null watermark and are always applied, so existing tasks adopt one on their next event. Claude-Session: https://claude.ai/code/session_014D6wif8HDRCXEFf5Ku4w7V * feat(ingest): accept deliveries forwarded by Hookdeck Adds verification for a Hookdeck-forwarded delivery and wires it into the Linear webhook ahead of Linear's own check. Both paths stay live: anything arriving without an x-hookdeck-signature verifies exactly as it did before, so a cutover is repointing a URL and a rollback needs no deploy. Linear is the route that needs this. Its signature covers a timestamp and the SDK enforces Linear's ±60s replay window, while Hookdeck preserves the original signature and timestamp when it retries — so re-checking Linear's signature behind a queue would reject every retry, which is precisely the delivery a gateway exists to save. Hookdeck's own signature is HMAC-SHA256 over the body alone and survives any delay. GitHub needs no equivalent change: its signature covers no timestamp, so it already tolerates arbitrary retry latency. x-hookdeck-verified is checked alongside the signature and is not redundant. The signature proves Hookdeck sent this; the flag proves Hookdeck authenticated the provider before forwarding. Without it, a source whose verification was never configured would hand us whatever anyone posted to its URL, correctly signed by Hookdeck and indistinguishable from real traffic. The secret is optional so environments without Hookdeck keep booting, but a signed delivery arriving somewhere it is unset is rejected rather than trusted — the alternative is trusting a header anyone can send. Claude-Session: https://claude.ai/code/session_014D6wif8HDRCXEFf5Ku4w7V * ci(deploy): pass HOOKDECK_SIGNING_SECRET through to the API GitHub Secrets are where runtime env originates in this repo — the deploy workflows map them into the environment the Vercel CLI reads, so a variable that is not listed here is never populated no matter what the schema says. Without it the Hookdeck branch would be unreachable in every environment and every delivery would quietly fall through to provider verification, which is the failure that looks like nothing being wrong. ci.yml needs no change: lint, typecheck and test never read it, and the schema marks it optional so a build without it still succeeds. Claude-Session: https://claude.ai/code/session_014D6wif8HDRCXEFf5Ku4w7V * test(desktop): add externalUpdatedAt to the task fixture The fixture spells out every column of TaskWithStatus, so adding one to the schema breaks it. Caught by CI rather than locally because the root typecheck spans 37 packages and I had only run the two I touched. Claude-Session: https://claude.ai/code/session_014D6wif8HDRCXEFf5Ku4w7V * fix(linear): keep the outbound watermark moving forward only CodeRabbit caught a hole in the guard two commits back. The outbound update wrote externalUpdatedAt unconditionally, so the watermark was not actually monotonic and the inbound guard was only as good as the value it compares against. The push goes out through QStash and can be retried, so its response can land after a webhook that already recorded something newer. Writing ours unconditionally then drags the watermark backwards, and the next stale delivery sails through the check that exists to stop it — the original bug, reachable by a slightly longer route. Gates the update on the same null-or-strictly-older predicate the inbound upsert uses. Skipping the write costs nothing: the webhook that overtook us already set lastSyncedAt and cleared syncError on its way past. Left the create path alone. A task being pushed to Linear for the first time has no watermark, so every write there is an increase, and gating it would risk dropping the external id linkage for no gain. Claude-Session: https://claude.ai/code/session_014D6wif8HDRCXEFf5Ku4w7V
* feat(mobile): wire every pull request card action Every card button except Merge showed a "not available yet" alert. The host gains markPullRequestReady, updatePullRequestBranch, reopenPullRequest and dequeuePullRequest (GraphQL ones resolve the node id first), all mapping GitHub refusals to non-500s through actionRejectionError — the generalized, exported successor of mergeRejectionError, with GraphQL rejections (no HTTP status) mapped to BAD_REQUEST. Mobile wires the plain actions through usePullRequestActions (one at a time, GitHub's own wording on refusal) and the "… with Agent" buttons through useAskAgent: one tap starts a fresh agent session in the workspace with the instruction already sent — no edit step, per the reference mocks — and lands on its tab. Prompts name the PR and the failing checks. * fix(mobile): fence check names in the agent prompt, guard duplicate actions Adversarial-review follow-ups: check names ride the PR head branch, so on a fork PR they are contributor text — they now enter the fix-checks prompt as quoted data with control characters stripped, length capped at 80 and count at 10. And usePullRequestActions now really is one-at-a-time: run() ignores taps while a mutation is in flight, since busyAction re-enables the other buttons the moment a second tap changes the variables. * fix(mobile): latch action submission synchronously, not on a render snapshot
…adevAI#7079) Counts say a row was rewritten. They cannot say the rewritten URL renders, which is the only thing that matters after repointing a thousand of them. Printing a few live URLs makes a run checkable by fetching one. Claude-Session: https://claude.ai/code/session_01QAQLVm3xTUXsJhpPQo3Nuf
* chore: remove Vercel Blob Nothing reads Blob any more. The last importer was the one-off pages migration script, which goes with it. BLOB_READ_WRITE_TOKEN was required in two schemas — z.string() in apps/api, .min(1) in packages/trpc — with no consumer behind it. That is the tripwire this change exists to defuse: deleting the secret at teardown while the schemas still demanded it would have failed API boot, and the variable would have looked entirely innocent to whoever deleted it. The next/image remotePatterns for the Blob host go too, in all five apps that carried one. Where Blob was the only remote host, the images block is gone rather than left as an empty array. Marketing keeps unavatar.io. DO NOT MERGE until the blob-port report confirms nothing still depends on Blob: no page version pointing at a Blob key, and no row still on a Blob URL. Claude-Session: https://claude.ai/code/session_01QAQLVm3xTUXsJhpPQo3Nuf * chore: remove the last Vercel Blob references outside app source A repo-wide sweep for "blob", rather than one scoped to apps/ and packages/, turned up three the earlier pass could not have seen. turbo.jsonc listed BLOB_READ_WRITE_TOKEN in globalEnv, which is part of the cache key: it would have kept hashing a variable that no longer exists. Two prose references named Vercel Blob as the example of a place to host an image — a desktop notices doc and a comment in the file viewer. Neither affects behaviour, both would have sent the next reader looking for a service we no longer use. Everything else matching "blob" is the browser Blob API or a blob: URL in a CSP, which are unrelated and stay. blob_pathname survives only in drizzle snapshots 0096-0099 as history; the column is long gone and snapshots are not edited. Claude-Session: https://claude.ai/code/session_01QAQLVm3xTUXsJhpPQo3Nuf
Clerk stopped being the auth provider in January. These three variables have no consumer in any app, no secret or variable in GitHub, and no reference in any workflow — but globalEnv is part of Turbo's cache key, so it kept hashing them. Claude-Session: https://claude.ai/code/session_01QAQLVm3xTUXsJhpPQo3Nuf
The Sigma template builds its date spine from exchange_rates_from_usd, which lands a day late, and then shifts it back another day. The series stopped two days short of today while the subscription events behind it were already current, so the admin MRR tile showed MRR from two days ago — $15,845 against Stripe's $16,150 — and read as stuck rather than as stale. The hourly refresh job was working the whole time; it was faithfully caching a query that had thrown the last two days away. Forward-fill the last known rates over a spine that runs to whichever of the two sources is newer. Verified against live Sigma: the series now reaches 2026-09-01 at $16,130. The residual gap to Stripe's dashboard is Sigma's own snapshot lag — its newest event at the time of writing was 14:07 local. Start the spine at the first subscription change rather than the first FX rate. SEQUENCE caps at 10k elements and the FX table reaches back to 2010, which would have walked the query into a hard failure some years out, over rows that are all zero MRR anyway. Claude-Session: https://claude.ai/code/session_018FrUA5omygopkJUSLf78H3
…ParadevAI#7085) * fix(trpc): carry the MRR series past the FX table's lag The Sigma template builds its date spine from exchange_rates_from_usd, which lands a day late, and then shifts it back another day. The series stopped two days short of today while the subscription events behind it were already current, so the admin MRR tile showed MRR from two days ago — $15,845 against Stripe's $16,150 — and read as stuck rather than as stale. The hourly refresh job was working the whole time; it was faithfully caching a query that had thrown the last two days away. Forward-fill the last known rates over a spine that runs to whichever of the two sources is newer. Verified against live Sigma: the series now reaches 2026-09-01 at $16,130. The residual gap to Stripe's dashboard is Sigma's own snapshot lag — its newest event at the time of writing was 14:07 local. Start the spine at the first subscription change rather than the first FX rate. SEQUENCE caps at 10k elements and the FX table reaches back to 2010, which would have walked the query into a hard failure some years out, over rows that are all zero MRR anyway. Claude-Session: https://claude.ai/code/session_018FrUA5omygopkJUSLf78H3 * feat(admin): give the MRR tile a refresh button that breaks the cache The tile is served from a 12h Redis entry that the hourly job keeps warm, so there was no way to ask for a recomputation short of waiting out the TTL — which is exactly what you want after changing the query behind it. Adds business.refreshMrr. It kicks a Sigma run and drops the cached entry, which is also what makes the tile's existing poll follow that run: getMrr serves the cache before it ever looks at a pending run, so an entry left in place would leave the new run uncollected until the next hourly job. The entry is only dropped once there is a run to replace it with, so a Stripe blip leaves the last good number on screen rather than trading it for an error. Kicking the run is all the mutation does. Sigma takes 30-60s and the tRPC route caps at 60s, so driving it to completion server-side would be a coin flip against the function timeout. InsightTileFrame already rendered onRefresh/isRefreshing as a spinning LuRefreshCw; MrrTile just never passed them. The tile now holds the series already on screen across the "computing" state rather than blanking for the minute Sigma takes — but only across "computing", so a real failure still surfaces as it did before. Claude-Session: https://claude.ai/code/session_018FrUA5omygopkJUSLf78H3
…oring and /md (ParadevAI#7083) * fix(marketing): stop the locale proxy from swallowing /ingest, /monitoring and /md The [lang] proxy added in ParadevAI#7002 matches every path without a dot, so three extensionless endpoints were rewritten to /en/... and 404ed from the moment it deployed (2026-08-29 21:46 UTC; marketing events stop at 21:49): - /ingest/* PostHog capture. Silent, because /ingest/static/*.js has a dot and slips through, so posthog-js loads and then every capture dies. Four days of marketing analytics lost. - /monitoring Sentry's tunnelRoute. Marketing error reporting equally dark. - /md/* Markdown twins for agents, serving an HTML 404 instead of the recoverable markdown 404 the route exists to return. apps/web and apps/api already exclude ingest and monitoring; marketing's matcher was written fresh instead of copying them. Named explicitly now. /md moves under [lang] rather than being exempted: the rewrite is then what makes it work, and the twins become localized -- /ja/md/page/pricing serves Japanese, which it never did. The route seeds i18n through initServerI18n like every other [lang] entry, and canonicals carry the locale. The deploy gate stayed green throughout: it fails on 4xx, but only for routes it lists, and it listed only pages. Adds a non-localized pass over the non-page endpoints. Claude-Session: https://claude.ai/code/session_01SduXRDYRusSKRKs6sFg31t * fix(marketing): satisfy biome import ordering Claude-Session: https://claude.ai/code/session_01SduXRDYRusSKRKs6sFg31t * fix(marketing): read the md twin's locale from params, not root-params app/i18n-server.ts resolves the locale through next/root-params, which is a server-component API. This is a route handler, and nothing proves root-params resolves in one — if it returned undefined the helper calls notFound() and every markdown twin 404s, which is the bug this branch exists to fix. [lang] is a parent segment, so the locale is already in this route's own params. Reading it there behaves identically in a route handler and a server component, and an unsupported segment returns the recoverable markdown 404 instead of an HTML one. Claude-Session: https://claude.ai/code/session_01SduXRDYRusSKRKs6sFg31t
users.clerk_id and organizations.clerk_org_id are left over from Clerk, which has not been the auth provider since January. Nothing reads or writes either column — checked by exact column name across the repo — and the tables themselves are still live, so this drops the columns rather than the tables. clerk_id was notNull().unique(), which made it more than dead weight: any future insert into the local users table would have had to invent a value for a provider we do not use. Migration generated by drizzle-kit, not hand-written. It drops each index before its column, which SQLite requires. Desktop applies local-db migrations on start (apps/desktop/src/main/lib/local-db/index.ts), so this reaches existing installs the next time they launch. organizationMembers in the same file has no references at all, and the users table itself is only imported by the schema. Both are Electric-sync leftovers rather than Clerk ones, so they are left for a separate decision. Claude-Session: https://claude.ai/code/session_01QAQLVm3xTUXsJhpPQo3Nuf
The tile's header timestamp is when we ran the query, which reads as how current the number is. It isn't: subscription_item_change_events_v2_beta is documented at 3h freshness and was measured 5h behind tonight, so a correct figure looked like a stale one and kept prompting "why doesn't this match Stripe". Emit MAX(event_timestamp) alongside the series and render it under the headline as "Stripe data through 2:07 PM", leaving the header's refresh time alone so both facts are visible and neither is overloaded. Deliberately not closing the last $40 against the Dashboard. That gap is subscriptions booked after Sigma's cutoff, and correcting for it means pricing them ourselves: workable for tonight's two plain USD signups, but it silently mis-sums the first non-USD subscription (the FX table is 24h stale), guesses at coupon-duration semantics, and mishandles metered and tiered prices. Those all fail as a confidently wrong headline rather than a visibly old one, which is the worse trade for a revenue metric. The FX-spine fix in ParadevAI#7084 already recovered $285 of the original $325. data_load_time is not the lever it looks like — in the Query Run API it returns the current time, not the data cutoff. Claude-Session: https://claude.ai/code/session_018FrUA5omygopkJUSLf78H3
…vAI#7087) * refactor(automations): one save state for the automation page The scope chips, the title and the agent wrote the moment they changed while the trigger set waited for a Save — two habits on one page, and the trigger set is the one that cannot autosave, since a half-built row is invalid by construction. Everything editable is a draft now and one Save commits them in a single patch. The draft moves up to AutomationBody: TriggersEditor is controlled, and the Save/Discard pair plus the problem banner move to the page header, where they also cannot drift down the page as triggers are added. Deliberately excluded: the Active switch, which is the kill switch and has to take effect on the click, and the prompt, which keeps its own save. * fix(automations): put the title into the draft on every keystroke On blur it raced the click on Save: the blur commits the rename to the draft and the click reads the draft, and a rename typed and saved without leaving the field could be dropped. Trimming moves to blur, where an emptied title falls back to the saved one. * refactor(automations): one draft hook for the page, and far fewer comments useAutomationDraft replaces the settings-draft plus useTriggerDrafts pair: one dirty flag, one adopt-when-clean, one save. useProviderOptions moves up to the page, which removes the savedAt/firstSave/biome-ignore workaround the Slack refetch needed. The prompt joins the draft and commits through the same Save, calling setPrompt only when it changed so versioning stays honest. Save and Discard move onto the title line. Comments cut to the ones that encode a constraint rather than restate the code. * fix(automations): drop the workspace pin when the project or device changes A pin belongs to one project on one host, so carrying it across a switch left a trigger that could not run and a picker showing two near-identical 'main' rows — the stale pin in amber beside the real workspace. Switching now falls back to 'New workspace', which is always valid; re-picking the same value leaves a good pin alone. * fix(automations): derive the page's scope from the draft, not the saved row The refactor moved the writes onto a draft and left the reads on the server value, so the page ran half-edited: picking a workspace wrote the saved project back and snapped the project chip, a switched device still listed the old host's workspaces, and the agent warning judged the saved agent. * fix(automations): save the prompt inside update's transaction The page saved through two calls — update, then setPrompt — so an update that landed followed by a failed setPrompt left a half-saved automation. update now accepts a prompt and records its version in the same transaction, and only when it changed, so a scope tweak doesn't mint an identical version. setPrompt stays for the CLI, MCP and SDK callers. Claude-Session: https://claude.ai/code/session_01JZADY3P3hZ7PitayKpGKLg * fix(automations): don't save twice when the prompt editor loses focus MarkdownEditor fires onSave on blur, so clicking Save blurred the editor and saved once from the blur and once from the button — two transactions per click. The prompt is already in the draft on every keystroke, so the blur has nothing left to commit. Claude-Session: https://claude.ai/code/session_01JZADY3P3hZ7PitayKpGKLg * chore(i18n): translate new strings across enabled locales * chore(automations): cut comments that restate the code Claude-Session: https://claude.ai/code/session_01JZADY3P3hZ7PitayKpGKLg * chore(i18n): recompile catalogs after the rebase Claude-Session: https://claude.ai/code/session_01JZADY3P3hZ7PitayKpGKLg --------- Co-authored-by: superset-i18n[bot] <i18n-bot@superset.sh>
…orkspaces (ParadevAI#7053) * fix(sandbox): ship agent templates, make start.sh re-runnable, and report crashes Cloud sandboxes had three independent defects, all of which would still be broken the day after an image rebuild. The image never copied packages/agent-setup/templates, so every boot logged "agent hooks will NOT work on this host" and eight setup actions failed. Agents ran but never reported starting, finishing, or asking a question — for as long as sandboxes have existed. host-service resolves them as `agent-templates` beside its own bundle, so they now land at /app/agent-templates, the same placement the CLI tarball uses. start.sh was not safe to run twice. Both of its branches destroy work in progress — the clone path wipes the directory, the fetch path moves the branch onto the remote's head and orphans commits an agent made and hadn't pushed. That was survivable only while nothing ever ran the script twice, which stops being true the moment anything restarts it or updates in place. The repository bootstrap is now fenced behind a marker written only on success, so an interrupted first run retries and every later run falls through to serving. Finally, sandbox crashes were invisible, and not because provisioning forgot a variable: the host-service Sentry DSN is compiled into the desktop bundle at desktop build time and handed over when the desktop spawns a host. A sandbox is started by the API and never sees a desktop build, so it could never receive one. Sandboxes now report to their own project via SENTRY_DSN_SANDBOX on the API — separate because that project means the host service on people's desktops, and because `release` means opposite things on the two surfaces: a version users happen to have installed versus one we deploy and converge. Events carry the cloud workspace id, image tag and provider; the workspace id is what joins a provisioning failure recorded against the API to a runtime failure recorded against the sandbox. Notably absent: restartOnFailure. Nothing supervises host-service, so a restart is the obvious reflex — but it has never been observed to exit. The container is healthy throughout the investigation above; what looked like a crash was the desktop calling procedures a 12-day-old image had never heard of. Restarting an unexplained exit would convert a bug worth investigating into a wobble nobody sees, and it does nothing for the failure actually hit since: a missing environment variable throws identically five times and dies anyway. Worth revisiting once startup errors reach Sentry, which they do not yet. Verified against a real sandbox provisioned from the live image and torn down afterwards. Full host-service suite (1439 pass) and trpc suite (122 pass) green. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * feat(trpc,db,desktop): environments as the starting point for cloud workspaces A cloud workspace used to start from BLAXEL_SANDBOX_IMAGE — one floating image name shared by every sandbox, so a rebuild silently changed what new sandboxes got and nothing recorded which build any of them ran. That value becomes a row. An environment is a named starting point owned by an organization, carrying the provider's reference as a discriminated pair: source_kind 'image' creates from a container image, 'snapshot' forks a sandbox someone already configured. Provider specifics stay in that one row rather than smeared across every workspace, so a change of sandbox provider touches one table. Forking is what makes agent-configured environments possible at all: there is no commit-from-sandbox path to an image, archiving is hibernation, and images build only from Dockerfiles. A fork inherits the source's environment variables and the fork request cannot override them, so identity is written in a second call — and because that update only reaches processes started afterwards, the source must be snapshotted with host-service stopped and start.sh exec'd only after it lands. Environments carry their own variables, which resurrects the secrets surface deleted in ParadevAI#6431 for being unreachable — it was cut for having no reason to exist, and now it has one. The crypto (AES-256-GCM) ports verbatim. The reserved key list did not: it guarded a previous provider's vocabulary and none of ours, so a user-supplied ORGANIZATION_ID would have overridden a sandbox's identity from a settings form. It now blocks by prefix and is re-checked at resolve time, so a row written before a name became reserved can never shadow identity either. cloud_workspaces gains environment_id (not null — the migration clears the two internal sandboxes rather than backfilling a value we would be guessing at, their provider sandboxes deleted first so nothing is orphaned), host_version for what a sandbox was last observed running, and deleted_at, which replaces overwriting status with "deleted" — that destroyed the record of why a workspace ended and carried no timestamp. Requires SECRETS_ENCRYPTION_KEY in the API environment, and at least one environment row before a cloud workspace can be created. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * chore(i18n): translate new strings across enabled locales * refactor(trpc): fork sandboxes through the SDK rather than raw HTTP The pinned @blaxel/core 0.3.11 already exposes `SandboxInstance.fork` and `updateSandbox` — the hand-rolled fetch calls were written on the assumption it did not, and no version bump is needed. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * feat(desktop): make environments reachable from settings The settings sidebar filters entries through getAllowedSectionsForVariant, which builds its allowed set from SETTINGS_ITEMS — so a nav item alone would still have been filtered out, leaving the page reachable only by URL. That is exactly why the surface this revives was deleted in ParadevAI#6431: "URL-only reachable, zero API calls in 90 days". Registers the section in both places, plus search keywords, and enforces Lingui on the new directory. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * chore(i18n): translate new strings across enabled locales * refactor(trpc): move environment secrets into their own sub-router environment.secrets.{list,set,remove} rather than environment.{listSecrets, setSecret,removeSecret}. Matches how integration composes its providers and how the deleted v1 project router nested secrets, and makes the directory honest — secrets/ held utils and validation but no procedures. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * refactor(trpc): read SECRETS_ENCRYPTION_KEY through the validated env process.env bypassed the schema the key was just declared in, so a deployment missing it failed at the first setSecret call rather than being visible in the one place env vars are described. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * feat(desktop,trpc): creating an environment only asks for a name The form asked for a source kind and an image reference, which nobody can usefully supply — a tag like `superset-hostsvc:hoockx6bbvtx` is meaningless to whoever is filling in the form, and forking is not something you type either. The base image is infrastructure we maintain, so the server fills it; forking sets these itself once an environment has a sandbox worth copying. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * style(desktop): match the settings list pattern on the environments pages The pages set no page padding at all and rendered bordered cards, neither of which the rest of settings does. Adopts the shape api-keys and the other settings pages already use: a `p-6 max-w-4xl` container, an `items-start justify-between` header holding the title, description and the primary action, `divide-y` rows rather than cards, an icon plus a truncating name over muted mono metadata, a hover-revealed destructive action, and the centered empty state. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * fix(desktop): center the environments settings pages fullWidth was copied from Hosts, which needs it because it renders its own sidebar. It opts a section out of the layout's `mx-auto max-w-4xl` column, so environments rendered flush against the left edge. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * chore(i18n): translate new strings across enabled locales * fix(desktop): show environments load failures instead of "no environments yet" A failed list query fell through to the empty state, so a table that does not exist reads as an organization with nothing in it — which is how a missing migration looked like a working page with no data. Also shortens the header description, which wrapped across the column and crowded the primary action. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * chore(i18n): translate new strings across enabled locales * feat: save a cloud workspace as a reusable environment Right-click a cloud workspace in the sidebar to turn the sandbox someone configured into a starting point for new ones. Promote forks the workspace's sandbox into one the environment owns rather than referencing the workspace itself. The workspace keeps running and can be deleted later, and the copy stays frozen because nothing runs in it — pointing at a live sandbox would instead re-copy it, mid-work, on every provision. The copy is then stripped of everything belonging to the workspace it came from. Each of these is a silent wrong answer if skipped, because a fork inherits processes, memory and filesystem alike: - host-service is stopped through the process API rather than killed, since the fork inherits restartOnFailure and a killed one returns still holding the source workspace's identity - host.db is removed, or every fork serves the source's seeded rows - the bootstrap marker is removed, or forks skip their own branch checkout - the source's GitHub installation token is cleared Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * chore(i18n): translate new strings across enabled locales * feat(desktop): port the original secrets UI onto environments Rebuilding the add form inline lost what ParadevAI#6431 already had: a side sheet with repeatable key/value rows and .env upload, a row with reveal and copy, and an edit dialog. Those come back close to verbatim — only the data layer and the scope changed, from a project plus organization pair to one environment. The container did not come back. It was v1 plumbing: Neon project linking, electronTrpc.projects, and the v1 project-settings header, none of which an environment has. secrets.getDecrypted comes back with them, since the row and the edit dialog need values. It keeps the original's rule that sensitive entries return empty rather than decrypted — the list edits the set, it does not recover a secret someone already stored. settings/environments comes back out of ENFORCED_DIRS while these strings are raw; converting copy that is still being reworded would be wasted, and the i18n pass follows once the screen settles. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * fix(desktop): say which variable was rejected and why "Failed to save environment variables" named neither the row nor the reason, and the save loop aborted on the first rejection — so earlier rows were already written while the toast reported the whole thing as failed. Rows are now checked before anything is written, the offending input is marked, and the reason sits under it. A rejection describes what was typed, so editing the row clears it. The reasons explain themselves rather than saying "reserved": a SUPERSET_ prefix carries the workspace's identity, HOST_SERVICE_ configures the sandbox's own server, and a bare name like PORT is set by the sandbox itself. Knowing a name is unavailable is not much help without knowing what owns it. The rules move to @superset/shared/environment-secrets so the form and the router check the same thing, rather than the client guessing at what the server will accept. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * fix(desktop): stop the secrets list resizing as you use it Three places where the row changed size for no reason the reader caused: Revealing a value swapped a 13-bullet placeholder for the value itself, each sized to its own content, so the row jumped. Both states now occupy the same box. The row-actions menu sat flush against the author avatar — the metadata block is flex-1 and the menu was a sibling with no gap between them. The empty state was py-8 against a row's py-4 plus 36px of content, so filtering a list down to nothing made the container taller than the list it replaced. It now matches a row exactly. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * fix(desktop): hover target hugs the value, and lighten the variable name The reserved width was on the value button itself, so the copy affordance highlighted the whole 200px reserved for it — a hover target covering empty space reads as a bigger control than it is. The width moves to a wrapper: the row still cannot resize on reveal, and the button stays intrinsic. The name was mono at semibold, which is heavy for a key sitting next to regular-weight metadata. Medium matches the name weight the other settings lists use. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * fix(desktop): show nothing while the variables list loads A one-row-tall placeholder still resized the moment real rows arrived, since nothing knows the count in advance. Rendering nothing is honest about that rather than approximating it badly. That only works if loading means first load. The parent was remounting the list with a key after every save, which reset it and would have blanked the list on each write; it now passes a refresh token, so a refetch leaves the existing rows on screen. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * fix(desktop): stop the environment header shifting when the name loads The heading rendered empty until the query resolved, and an empty heading has no line box at all — so the header had zero height and grew once the name arrived, pushing the list down. It now always holds exactly one line: the name, or a non-breaking space while it loads. A min-height was the obvious fix and measurably did not work, so this uses the shape that cannot shift rather than one that looks like it should. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * fix(desktop): center the environments headers against their text block The action button was items-start, so it sat level with the title while the description hung below it — reading as two rows rather than one. Both headers now center against the whole block, and the description gets a max width so it stops running up against the button. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * fix(trpc): hand a fork its identity in a file, not a sandbox update Forking was calling PUT /sandboxes/{name} to give the fork its own env vars. That endpoint re-materialises the sandbox from its image: verified on a plain sandbox on sandbox-api 2026-08-27, where files under /data, /root, /opt, /workspace and /tmp all vanished and a git repo baked into the image came back clean. The root filesystem is a `volatile` overlay, so nothing written at runtime survives. Forked environments were losing exactly the configured state they exist to carry. Provisioning now writes /data/identity.env after forking and start.sh sources it, which touches no spec. Both sites are marked TODO(2026-09-01) and go away once a fork can be created with its own environment variables — Blaxel are integrating that into fork directly. Verified end to end against real sandboxes: a workspace forked from a promoted golden keeps the agent's files and the golden's baked env, and resolves its own workspace id rather than the source's. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * feat(trpc): provision sandboxes with 8GB rather than 4GB The root filesystem is a RAM-backed overlay, so memory is not just process headroom — everything written at runtime comes out of the same number. A warm checkout, node_modules, an agent and a dev server together do not fit comfortably in 4GB. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * chore(skills): allow applying a migration to a dev branch The rule read 'never apply one yourself', which is right for anything shared and wrong for the throwaway Neon branch a worktree owns — where not applying means the schema change goes untested until deploy. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * feat(trpc,db): ship environments owned by no customer An environment every organization can start from has to belong to someone. A sentinel organization owns them, rather than a copy per customer: rebuilding an image then re-points one row instead of needing a backfill across every organization that existed when it changed. Sharing an environment surfaced two faults that were invisible while every environment had exactly one owner: - resolveEnvironment and all four secrets procedures selected on environmentId alone. Correct when the environment implies its owner; a cross-tenant credential leak the moment one does not. Every query is now scoped by organization, using ctx.activeOrganizationId, which already existed. - unique(environment_id, key) meant two organizations could not both set DATABASE_URL on a shared environment — the second silently overwrote the first through onConflictDoUpdate. Migration 0104 widens it to include organization_id. Shared rows are readable by everyone and writable by no one: list unions them, loadEnvironment permits them, and every mutation refuses them so one tenant cannot rename or archive what everyone else uses. Also drops MAX_SECRETS_PER_ENVIRONMENT. It was an invented number guarding nothing the size limits do not already guard, and the provider accepts 250 variables — measured, not assumed. MAX_TOTAL_SIZE was declared but never enforced; it now is, in SQL over the stored values. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * feat(sandbox): give a fork its own identity, and stop shipping our repo Blaxel fixed the update path that made the workaround necessary: an env-only change no longer resets a sandbox to its image. Verified by isolating it — a no-op PUT and an image-only change both leave the writable layer intact, and only an env change destroyed it. forkSandbox now sets the workspace's identity directly and /data/identity.env is gone, along with the block in start.sh that sourced it. The merge keeps what the fork inherited. Values the API redacts to '****' on read are preserved when written back unchanged, which is what lets the proxy credential placeholders survive untouched — confirmed by forking after a masked write and reading the values from inside. The image also stops carrying the monorepo. It was 230 MiB of our code in the image every customer gets, and it sat below a bundle that changes every build, so it re-cloned each time. Removing it takes the build from ~7 minutes to 2m53s and the image from 2.85 GB to 2.54 GB, and costs nothing at boot: a blobless clone reaches health in 10s. Our own checkout is warmed in the internal environment instead. What the image gains is what it was missing: procps (there was no ps or pgrep), iproute2 (no ss, so listener checks silently reported nothing), tzdata, locales, git-lfs, and the network tools that make a failure diagnosable from inside the box. Plus a virtual display and VNC server, since a GUI is a capability rather than taste. Agent CLIs and bun are pinned — unpinned installs in a cached layer are a version nobody chose. Electron's ~400 MB of libraries stay in the internal fork, with the shell configuration, because nobody else develops an Electron app. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * feat(host-service): serve a sandbox's display over an authenticated socket RFB is raw TCP and a browser cannot open a TCP socket, so the bytes have to arrive over a WebSocket — which is what noVNC expects anyway. This pipes one to the other and understands nothing about the protocol, so versions and encodings are negotiated between the client and x11vnc without this sitting in the middle. Same shape as the browser CDP route, with a TCP upstream instead of a WebSocket one. Behind the same wsAuth as /terminal/* and /browser/*, and the port is fixed rather than taken from the request: a caller-supplied port would turn an authenticated socket into a way to reach anything on loopback. x11vnc binds -localhost, so this proxy is the only way in. Verified against a real display: an authenticated socket returns 'RFB 003.008' from x11vnc. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * fix(sandbox): report crashes to the deploy they came from SENTRY_DSN_SANDBOX now reaches the API in both deploys, against a dedicated Sentry project — sandboxes fail differently enough from the desktop's host service that sharing a project would mean sharing alerts and quotas for two unrelated fleets. The environment tag was hardcoded to 'sandbox', which the project already means, so every event carried a constant. It now carries the deploy — preview and production sandboxes report to one project and this is the only thing telling them apart. Same vocabulary the api app and apps/web already use. Sandbox tags lose their guards. Undefined values are dropped when the event is serialised, so a desktop host simply carries none of them, and run_mode reports the mode it is actually in rather than a hardcoded one. Note this still cannot see the failure it most needs to: env validation throws while serve.ts's imports are evaluated, above the catch that calls captureFatalStartupError, so a missing variable in the deploy reports nothing. That fix is separate. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * refactor(desktop,db): one migration, dotenv's parser, a pending toast Three review notes, none of which change behaviour we shipped. The environments migration was a table creation followed immediately by an ALTER widening a constraint that had never been deployed. Neither is on main, so they collapse into one that simply creates the table with the constraint it should always have had. The .env parser was hand-rolled and disagreed with itself: validation matched lines against one pattern and parsing split on the first '=', so a file could pass the check and then parse into something else. It now derives validity from parsing, and the grammar is dotenv's rather than an approximation — dotenv cannot be imported in a renderer, since its module root pulls in fs, path, os and crypto, but the parser is one expression and is reproduced with attribution. That fixes backtick quotes, inline comments, escaped newlines, and CRLF files leaving a carriage return inside multi-line values. Checked against dotenv itself across twelve cases; no disagreement. Promoting a workspace forks a sandbox and waits on a cleanup exec, so it runs for seconds with nothing on screen. toast.promise covers the gap, which is what the rest of the app already uses for slow mutations. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * chore(desktop): note where promote-to-environment belongs Reachable only from a workspace's context menu today, and offered on workspaces that cannot be promoted — the action requires status 'ready' and the menu gates only on the host being a cloud one. TODO(SUPER-2116) marks the site; the ticket carries the design. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * fix(db): stop defaulting a secret to readable The column defaulted sensitive to false while the API's input schema defaults it to true, so the two disagreed on the answer — and the column's was the permissive one, since false is what makes a value come back decrypted in the editor. It was never reached: the router is the only insert and always passes the flag. That is the argument for removing it rather than flipping it. A dead default that agrees is still a default nobody consults, and NOT NULL with none forces the case that would actually use it — a seed, a backfill, a script — to say what it means. Folded into 0103 rather than added on top; it has not shipped. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * fix(db): clear cloud workspaces before requiring an environment The migration added environment_id as NOT NULL with no default, which fails outright on any database that already has cloud workspaces — production included. It only passed here because the rows had been deleted by hand first, which is not a thing a deploy can do. There is no honest default to backfill: an environment decides the image a sandbox boots from, so picking one for a row that predates environments would be inventing an answer. The rows go instead, authorised deliberately — a handful of internal workspaces behind the @superset.sh gate, all owned by one person. Their sandboxes outlive this and have to be torn down separately. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * refactor(sandbox): one name for the image, and a seed that actually runs seedSharedEnvironments had no caller. Not in app code, not in any workflow — so in production the shipped environment was never created, every organization would have opened an empty list, and the sentinel org that list unions against did not exist. It now runs after migrations in both deploys, which is the only moment it can: it writes rows in the tables those migrations create, and re-points them at the current image. It also moves out of packages/db. The seeds there are a library called from app code — seedDefaultStatuses runs on organization creation, and from linear and task — while this one is a CLI nobody imports. It lands beside image.ts, which names the very image it points rows at, and scripts already reach into packages for exactly this reason. Dropping its read-back is what let it move: the select only re-read what the upsert had just written, and was the sole reason the file needed drizzle-orm, which the root package does not carry. The image name was configuration three times over — image.ts named it, a deploy secret named it again, a local .env named it a third time, and nothing checked the three agreed. A build producing one name while the secret said another would have pointed provisioning at an image that does not exist. It is now a constant in @superset/shared that the build derives from and BLAXEL_SANDBOX_IMAGE overrides, so the fallback is right rather than absent. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * fix(trpc): let a workspace start from a shipped environment Creating a cloud workspace matched the environment on the caller's own organization, so the environments the platform ships — owned by a sentinel organization nobody is a member of — came back as 'not found in this organization'. list, loadEnvironment, the secrets procedures and resolveEnvironment were all taught about shared rows; this consumer was missed, which left the shared environment visible in the list and unusable by the only thing that consumes one. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * fix(desktop): wire environments into settings search The section registered ENVIRONMENTS_LIST and ENVIRONMENTS_SECRETS as searchable, but nothing referenced them: the page took no visibleItems and anchored neither id, so searching for either navigated here and highlighted nothing. The route now resolves the query the way the appearance route does and the section honours it. That is also what the thin route shell was for — it looked redundant because the work it exists to do had not been written yet. Two dependency warnings while here, with different causes: secret.id was genuinely unread by the delete callback and goes; refreshToken is a trigger the parent bumps to force a refetch, so the effect must re-run on a value its body never reads, and that is now stated rather than silently unusual. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * chore: drop explanatory comments Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * refactor(trpc): use the filesystem API to strip a fork's identity Six typed removals instead of a shell string joined with &&. pkill stays an exec: pty-daemon is spawned by host-service, so the process API cannot see it. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * feat(trpc): bind an encrypted secret to the row that owns it GCM additional authenticated data over (environmentId, organizationId, key), so a ciphertext moved to another row fails to authenticate instead of decrypting. That is the same invariant the queries enforce, made structural: a bad migration or a query that forgets its organization filter now yields an error rather than another tenant's value. A version byte goes in front so the format can change without reading and rewriting every row. Both are free today and cost a backfill once there are secrets in production, which there are not yet. Verified: same row decrypts, a different organization and a different key are both rejected. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * fix(shared): raise the environment secret budget to 128KB 64KB was a number picked before the platform's own ceiling was known. Blaxel accepts an env payload up to somewhere between 192KB and 256KB — measured — so the guard has room to sit further from anything legitimate while still firing before the platform does, which matters because its failure is 'Failed to create sandbox' with no detail. The sum is over ciphertext, so the usable plaintext budget is nearer 96KB. A real .env is about 13KB. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * chore(trpc): drop explanatory comments from env Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * chore: remove explanatory comments Files this branch created keep nothing but a header where the code is genuinely unattributable — the dotenv grammar, the pty-daemon exec. Files it modified keep what main already had, restored verbatim, except one comment in start.sh that a change made false and which is now corrected in place rather than rewritten. What survives is load-bearing: TODO(SUPER-2116), a biome-ignore whose absence fails lint, and the dotenv attribution. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * chore(desktop): remove the last explanatory comments Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * chore: rebase onto main Migration renumbered to 0104: main landed 0103_stage_page_assets while this branch was out. Regenerated rather than renamed, and the authorised cloud_workspaces wipe re-added, since generation drops it. root.ts keeps environmentRouter and drops fileRouter, which main deleted in ParadevAI#7046. Catalogs recompiled from the .po sources, and the one string this branch adds is now translated across all sixteen locales — strict compile fails the build otherwise. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * feat(desktop): a Desktop pane for cloud workspaces Pairs the renderer with the /desktop/vnc route, which until now had no way in. noVNC's RFB client against the authenticated socket: the token goes in the query string because a browser upgrade cannot carry headers, which is the same reason getHostServiceWsUrlParams exists for terminals. Offered wherever the workspace resolves to a sandbox, rather than gated on a capability probe (SUPER-2111). The route already closes with 'No desktop session on this host' when nothing is listening, so a sandbox without a display shows that rather than hanging. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * feat(desktop): offer the Desktop pane from the + menu and a hotkey Three ways in now, all limited to workspaces that resolve to a sandbox: the pane context menu, the tab bar's + menu, and SPLIT_WITH_DESKTOP. The hotkey is meta+shift+y rather than the +d that would have matched 'desktop' — SPLIT_DOWN already holds that chord on every platform. react-hotkeys-hook's "enabled" keeps the binding inert on a local workspace instead of splitting into a pane that cannot connect. Eight new strings, translated across all sixteen locales. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * fix(ci): undo a bad rebase resolution in the deploy workflows Resolving the rebase with --theirs kept this branch's stale copy of both deploy workflows over main's newer one, silently reverting ParadevAI#7074 and part of ParadevAI#7078: secrets.* went back to vars.*, MEDIA_URL and BLOB_READ_WRITE_TOKEN came back from the dead, and R2_PUBLIC_BUCKET, R2_ENDPOINT and STATIC_URL were dropped. Reset both files to main and re-apply the only change this branch actually wants, SENTRY_DSN_SANDBOX. The shared-environment seed step goes with it; seeding three rows into a database that outlives the deploy is a one-time manual step, not a pipeline concern. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * fix: green the build and close the review findings Two CI failures, both real: - The desktop build could not resolve @novnc/novnc/core/rfb.js. That package's exports field is the bare string "./core/rfb.js", so it publishes only the package specifier and no subpaths at all. Vite's dev server resolved it anyway, which is why this only surfaced in the Rollup build. - Mobile could not call cloudWorkspace.create, which had grown a required environmentId that only the desktop picker supplies. Make it optional and resolve the shared default server-side, the way branch already resolves the repo's default: a client with no environment picker must not guess an id. Review findings worth fixing before this ships: - Promotion carried the promoting workspace's env into the golden sandbox. Blaxel copies env on fork and it is immutable afterwards, so a shared environment would hand every later workspace the promoter's git token. Provisioning sets that token only when the clone needs one, so a public-repo workspace inherited it rather than overriding it. Strip workspace identity from the fork's env, mirroring the identity files already removed. - Promotion swallowed every stop and unlink failure, so an identity-bearing host service could stay running in an environment we then published as clean. Only absent paths and processes are ignored now. - useWorkspaceHostTarget kept the sandbox URL unkeyed, so switching cloud workspaces reported the new one ready at the old one's address, pointing panes at another workspace's sandbox. Key the grant to its workspace. - loadEnvironment ignored archivedAt, leaving archived environments readable and mutable through get, update, archive and every secrets procedure. Seeding gets a manual, credentialed workflow rather than a per-deploy step, so a fresh database can be seeded on demand without the pipeline carrying one-time data setup. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * feat: pick the environment a cloud workspace is created in Cloud workspaces took whichever environment sorted first, on both clients: desktop read environments[0] at submit, and mobile could not send one at all. Neither surface showed which environment a workspace would get, and neither let anyone choose. Desktop gains a fourth pill beside the project, rendered only when the device is Cloud. Mobile puts the picker in the slot the inert "Cloud" chip occupied — under cloud scope that chip named the place and did nothing, so the row now reads as the environment and its own picker screen sits beside the project, branch and agent ones. Both remember the pick the way they already remember the host, and both fall back to the first environment until someone chooses, so the default is the behaviour that shipped rather than an empty picker. environmentId stays required on create: a client with no picker is a client that cannot say what it wants, and the server guessing a default hides that. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * chore(i18n): translate new strings across enabled locales * fix(desktop): show the environment picker on the new-workspace route too The create surface exists twice: DashboardNewWorkspaceForm's PromptGroup, and NewWorkspaceScreen, the route arm of the new-workspace-screen experiment, which does not reuse PromptGroup — it imports the pills and composes its own row. The picker only landed in the first, so the surface actually rendered showed Cloud, project and branch with no way to see or choose the environment. Both share useSubmitWorkspace, so the pick was already honoured once made; only the control was missing. Caught by driving the running app rather than reading the diff. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * fix(mobile): keep the Cloud chip and put the environment beside it The environment picker took the Cloud chip's slot instead of joining the row, so choosing an environment cost you the label naming where the workspace runs. Cloud goes back to naming the place, and the environment is its own chip after it, which is the arrangement desktop already has. Falls back to "Environment" rather than "Cloud" now that Cloud is its own chip again, and the Cloud chip returns to being inert on press. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * chore(i18n): translate new strings across enabled locales * feat: cloud workspaces have no project A cloud sandbox never had a project in any sense a person would recognise — no worktree, no folder, nothing to switch between. It carried one because the project was the only thing naming a repository to clone, so picking a project was really picking a repo, spelled wrong, and it dragged a project picker into two composers that had nothing to do with projects. The repository is now named directly. Every cloud workspace clones the same one, which is true today and is what the environment should carry instead (SUPER-2119) — so the constant is the honest placeholder rather than a project id standing in for a repo. - cloud_workspaces.project_id is gone, with its foreign key and index. - create and listBranches no longer take a project; repoForProject becomes repo; listProjects is deleted, which its own comment already called interim pending exactly this. - resolveCloneTarget and listRemoteBranches take a repo, not a project id, so repo-for-project.ts has no callers left. - SUPERSET_SANDBOX_PROJECT_NAME had one source and no replacement; dropped. - Desktop hides the project pill under Cloud and stops requiring one. - Mobile offers a single cloud target rather than one per project, and its Home list was already flat under cloud scope, so nothing regroups. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * chore: retrigger ci The last two pushes produced no workflow runs at all, so nothing has checked the project removal or applied 0105 to the preview branch. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * fix(sandbox): start the display server the desktop pane connects to The image installs Xvfb, openbox and x11vnc and host-service proxies /desktop/vnc onto 127.0.0.1:5900, but nothing ever launched them, so a fresh sandbox answered the pane with "No desktop session on this host". The earlier RFB handshake only passed because the test started them by hand. x11vnc binds loopback only; the pane reaches it through host-service's authenticated proxy. All three are fire-and-forget: a missing display costs the pane, never the workspace. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * test(sandbox): a smoke test that provisions a real sandbox Runs the same provisionSandbox the API uses, then checks what a workspace actually needs: host-service answering, the repo on the requested branch, the bootstrap marker, the display server, and an RFB handshake through host-service's /desktop/vnc proxy. Deletes the sandbox either way. It deliberately skips the API wrapper — the row, the QStash delivery, and the GitHub App token mint. The mint needs the production App key, which is why the clone here is unauthenticated and the repo must be public. bun run sandbox:smoke Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * fix(sandbox): run the smoke test from the root so .env loads Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * test(sandbox): --keep and SMOKE_WORKSPACE_ID for poking at a live sandbox --keep leaves the sandbox up and prints its preview URL. SMOKE_WORKSPACE_ID seeds the host's workspace row with a given id, which is what lets a real cloud_workspaces row open it in the app: the workspace layout shows the provisioning screen until the host reports a workspace with the same id. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * fix: close the last review findings - start.sh waited a fixed second before starting openbox and x11vnc; on a slow Xvfb both die and the pane has nothing to connect to. Wait for the display socket instead. - useCloudRepoPrefix asked the API for the cloud repo even when cloud workspaces are off for the account, which is a FORBIDDEN on every Home load. Gate it the way the other cloud hooks are. - The create args carried a target nothing read once the project went away. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY * fix(i18n): stop regenerating catalogs inside typecheck turbo's typecheck task depends on ^build, not ^typecheck, so @superset/i18n#typecheck ran alongside every dependent's tsc. Its pretypecheck rewrote all seventeen locales/*/messages.ts while admin, desktop, mobile and the rest were type-importing them, and whichever read mid-write got a truncated file — the intermittent "unterminated string literal" in es/messages.ts, and today a ko catalog whose export no longer typed. The regeneration was already redundant: CI's Lint job runs the i18n check, which extracts, compiles --strict, and fails on any diff against the committed catalogs. Typecheck now reads those committed files and nothing writes them concurrently. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY --------- Co-authored-by: superset-i18n[bot] <i18n-bot@superset.sh>
…ground (ParadevAI#7089) destructive-foreground is the label colour for text sitting on a bg-destructive surface, so themes set it to their page background. Fifteen sites used it as a freestanding red (diff deletion counters, check-failure icons, closed-PR and changes-requested badges, attachment warnings, file-pane error banners). It only looked right on the default dark theme, whose value happens to be pale pink; on Kintsugi the -484 next to +24636 rendered black on black, and on the light theme it was near-white on white. Switch them to text-destructive so every theme gets its own red. The three real button and hover cases keep the pair. Claude-Session: https://claude.ai/code/session_01WNYfXfvbc1km7yfoRKk9b5
…evAI#7088) The shared environment was seeded into production on 2026-09-02 (run 33592305966). A one-time data step does not belong in the workflow list; the script stays for dev and preview branches: bun run db:seed-shared-environments. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY
docs/environment-variables.md says always a secret, never a repo variable, so that one mechanism means one place to look when a value goes missing. Five variables predated the rule: R2_PRIVATE_BUCKET, GH_APP_SLUG, SUPERSET_API_URL, SUPERSET_WEB_URL and NEON_PROJECT_ID, across nine references in five workflows. Both build-cli.yml callers already pass secrets: inherit, and it already reads secrets.RELAY_URL beside the two it did not; cleanup-preview.yml already needs secrets.NEON_API_KEY, so a variable bought its project id nothing. The secrets are set with the values the variables held, so no deploy changes behaviour. Note the workflow_call inheritance trap in the doc, since that is the one way this change could have failed silently. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY
The image a sandbox boots from is the environment row's sourceRef, and has been since environments landed; provisioning never read this variable. It survived in two places — environment.create defaulting a new row, and the shared-environment seed — and both now use SANDBOX_IMAGE_NAME directly. Removes the schema entry, both deploy workflows' pass-through, and the three scripts that fell back to it. The GitHub secret can be deleted after this merges. Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY
Adds three MCP tools over the host tRPC procedures that already back Settings → Agents and `superset agents presets` (ParadevAI#7269): - agents_presets_add → settings.agentConfigs.add - agents_presets_edit → settings.agentConfigs.update - agents_presets_remove → settings.agentConfigs.remove `agents_create` stays the launch verb; preset CRUD is namespaced under `agents_presets_*` so it survives the roadmap's `agents_run` rename. The launch shape is accepted two ways — structured `command` + `args`, or a single `launchCommand` line the CLI's tokenizer splits — and passing both is rejected rather than resolved by precedence. `command` and `args` always replace each other as a pair, so a stored launch line is never half of one caller's intent and half of an earlier one's. `tokenizeAgentCommand` is exported from @superset/shared rather than re-implemented; it was already the private splitter behind the seed presets.
|
Important Review skippedWe couldn't safely recover the incremental review. No full review was started, and the last reviewed checkpoint was preserved. Retry later, or explicitly request a full review by commenting You can disable this status message by setting the Use the checkbox below for a quick retry:
ℹ️ Recent review info
Walkthrough
Merge Risk:
|
| Check name | Status | Explanation | Resolution |
|---|---|---|---|
| Docstring Coverage | Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 7 functions across 12 files. (1 skipped: … | Write docstrings for the functions missing them to satisfy the coverage threshold. |
✅ Passed checks (4 passed)
-
Check name Status Explanation Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request. Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request. Title check ✅ Passed The title uses conventional commit format and clearly identifies the primary change: adding MCP tools to add, edit, and remove agent presets. Description check ✅ Passed The description includes the required What & why, How I tested it, and Checklist sections. It clearly explains scope, implementation, testing, and out-of-scope items. The repository checklist is adapt…
Full details: Docstring Coverage
-
Explanation
Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 7 functions across 12 files. (1 skipped: 1 unsupported.)
✨ Finishing Touches
-
🧪 Generate unit tests (beta)
-
- Create PR with unit tests
-
- Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
Comment @coderabbitai help to get the list of available commands.
agent-wrappers.test.ts replaces "./paths" with mock.module() for the whole process and never restores it, and bun runs a package's test files in one process in no guaranteed order. When that file loads first, resolveSupersetHomeDir() in disabled-agent-hooks.test.ts ignores SUPERSET_HOME_DIR and resolves into agent-wrappers' root instead. The round-trip test's write then lands in a file the afterEach here never deletes, so "treats a missing or corrupt file as nothing disabled" reads ["claude", "codex"] and fails. Give each test a fresh mkdtemp home, and clear the state file at the path the tests actually read so a stale write cannot survive whichever module graph is in effect. Same shape disabled-skills.test.ts already uses.
|
@coderabbitai full review |
✅ Action performedFull review finished. |
|
19387b9 to
0e1c5a0
Compare
|
I have read the CLA Document and I hereby sign the CLA 1 out of 6 committers have signed the CLA. |
|
I have read the CLA Document and I hereby sign the CLA |
* chore: seed CLA signature file * @tanxarx has signed the CLA in #8881 * @ozeitis has signed the CLA in #8888 * @shubhankiit has signed the CLA in #8894 * @chrisfentiman has signed the CLA in #8910 * @RohithReddy20 has signed the CLA in #8907 * @larcane97 has signed the CLA in #8922 * @moizghumann has signed the CLA in #8935 * @andyst-dev has signed the CLA in #7930 * @svenpeeters has signed the CLA in #8943 * @kenil-kanani has signed the CLA in #8940 * @akshatcoder-hash has signed the CLA in #8963 * @NilotpalK has signed the CLA in #8995 * @serhii-kucherenko has signed the CLA in #9000 * @tonydzi has signed the CLA in #9002 * @adaam2 has signed the CLA in #9038 * @tsavo-at-pieces has signed the CLA in #9059 * @ganeshmshetty has signed the CLA in #9130 * @aaronrosenthal has signed the CLA in #9135 * @alexnault has signed the CLA in #9138 * @kunalgoesbyken has signed the CLA in #9235 * @Joaco-gavernet has signed the CLA in #9236 * @theblondealex has signed the CLA in #8240 * @talarczykco has signed the CLA in #7274 * @throw99 has signed the CLA in #7906 * @lacherogwu has signed the CLA in #9310 * @KGKallasmaa has signed the CLA in #9316 * @Niltonsf has signed the CLA in #9331 * @Anmol1578 has signed the CLA in #9351 * @RyoKusnadi has signed the CLA in #9317 * @thedotmack has signed the CLA in #9414 * @abudker has signed the CLA in #9439 * @sanchitmonga22 has signed the CLA in #9443 * @Bruno-rose has signed the CLA in #9446 * @Ashad001 has signed the CLA in #9487 * @vivek-pothina has signed the CLA in #8359 * @colingreig has signed the CLA in #9489 * @anmol0b has signed the CLA in #9492 * @resteqs has signed the CLA in #9506 * @pgilmore has signed the CLA in #7878 * fix(desktop): name the DMG from the display name The DMG file and its volume title now use DESKTOP_DISPLAY_NAME, so a Paradev build ships Paradev-<version>-<arch>.dmg. Unset, names stay Superset. The release keeps the Superset-<arch>.dmg alias beside the new one, since the README, docs and download button link to it. The .app bundle and the -mac.zip update files keep the Superset name. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
What & why
I could list and launch host agent presets over MCP, but I couldn't author one — still Settings → Agents by hand. That gap hits anyone driving agents from MCP (and anyone stacking on #7269 CLI presets): you can run a preset you can't create remotely.
This closes it with
agents_presets_add|edit|removeon the samesettings.agentConfigs.*/hostAgentConfigspath as the CLI.agents_createstays launch; CRUD isagents_presets_*. Verbeditmatches CLI (host procedure stillupdate). Launch shape: structuredcommand+argsprimary, orlaunchCommand(tokenized); dual-reject.tokenizeAgentCommandfrom@superset/shared. Out of scope: reorder/restoreDefault/iconId/resumeArgs/forkArgs/SDK.Also carries the #7276 agent-setup Test isolation cherry-pick so owned Test means what it says on this tip.
How I tested it
Tip:
19387b99c644d81d85864acca2a560b28d8ad1f9(=dfe48dbdf+ #7276 isolation cherry-pick).bun testinpackages/mcp: 20/20 (mockhostServiceCall— structured/launchCommandadd, dual-reject, empty binary/patch, pair replace, remove id echo). Typecheck (mcp + host-service), shared suite, biome, sherif.447e38a1…; add/edit/remove round-trip; dual-shape + empty-patch reject; cleaned after.Checklist
tokenizeAgentCommandexport in@superset/shared); no CLI / host-service / dock / SDK CRUDApproach issue: #7370