Skip to content

0.2.1 - 2026-09-17

Choose a tag to compare

@github-actions github-actions released this 17 Sep 10:35

Release Notes

Added

  • Multi-item fs tools (read_files / edit_files) — several operations in ONE call (cap 16 items), cutting tool-flight round trips and giving multi-edit workflows real atomicity. read_files takes files: [{file_path, offset?, limit?}] — one segment per item with read_file's exact semantics behind a === path === header; a failed item reports its error inline and the rest still read (all items failing → one consolidated error). edit_files takes edits: [{file_path, old_string, new_string, replace_all?}] — same-file edits apply in listed order on the content the previous edit left (later edits may target text earlier edits introduced), and each FILE is atomic: one failed edit leaves that file untouched while other files still apply, so the report shows exactly which files were written and which edit (by its 1-based index in the model's own list) failed, with the nearest-line hint. Upfront validation rejects empty/identical/exactly-duplicated items and the cap before any I/O; CRLF handling generalizes edit_file's gate (any \r-carrying old_string keeps that file in raw space). Under the hood: a new #[derive(ToolItem)] proc-macro generates object schemas for nested array-of-object parameters (the Tool derive delegates Vec<Item> items to <Item>::item_schema() for non-primitive item types — a missing derive fails compilation), and read_file/edit_file remain as the single-item path over the same extracted cores (read_file_segment, apply_edit_ops), their pinned schemas unchanged. The web tool-card summary renders multi-item args as "N items: ".

  • The e2e harness runs axe-core accessibility scans — a devDependency whose SOURCE the harness injects into the page (the runtime harness stays zero-dep): three surfaces per run (the fresh chat's empty state, the rendered conversation with prose + both verdict cards, the 390×844 mobile chat) must carry no critical/serious violations. The color-contrast rule is off by documented design (the faint token's tiered hierarchy — tokens.css) and region off (the shell is not landmark-partitioned). The first run caught three real defects, all fixed: the notification bell's unread badge was INSIDE the DropdownMenuTrigger's asChild span, so the trigger's button semantics landed on a span (aria-allowed-attr, critical) and AT lost the expanded state; the sidebar chat row was role=button with the ⋯ menu INSIDE it (nested interactive, serious) — the row and menu are now siblings under a positioning wrapper, same geometry, same keyboard path; prose links had no underline (Tailwind preflight strips it) and color alone is under the distinguishability threshold in the dark theme (link-in-text-block, serious) — .prose a underlines explicitly.

  • Coverage floors + a CI bundle treemap — vitest coverage thresholds act as regression floors set just under the measured baseline (global 80/75/65 lines/functions/branches; src/lib/** 92/85/80; src/core/** 72/72/52 — the wire translation is exercised mostly through the e2e harness, not jsdom), so a silent coverage drop fails CI instead of passing quietly; verified the mechanism fails an impossible threshold. FLUX_BUNDLE_ANALYZE=1 (the CI web job sets it) emits a rollup-plugin-visualizer treemap to dist/stats.html, uploaded as a CI artifact — build-time only, never shipped.

  • A skeleton primitive joins the loading voices — components/ui/skeleton.tsx: static, token-derived shape placeholders (a fg wash reads on every surface) with the codebase's existing gentle opacity pulse — no shimmer sweep, the placeholder promises shape, not progress. Applied where a known shape is seconds away: the terminal host's first frame (undefined/connecting states paint three quiet bars instead of a blank inset void) and the Providers panel's first uncached catalog probe (the preview pane holds the "Upstream catalog" block's shape while the upstream round-trip runs — the badge alone carried it before). Deliberately NOT applied where loading is either instant or invisible: the "load earlier" pagination is a local slice over already-fetched messages, and the @-mention popup appears only once its listing resolves (a skeleton flash would add motion to a completion gesture).

  • File references in the composer (@-mention + drop-to-reference) — typing @ opens a path completion popup over the composer (services/mention.ts): workdir-RELATIVE completions with /-drill-down (choosing a directory keeps the @ and continues into the next segment; choosing a file consumes it and trails a space), dirs first, capped, matched case-insensitively on the segment after the last /. Directory listings cache per session with a 15s TTL — the Explorer's refresh rhythm — so a file the agent JUST created is completable; the cache is independent of the Explorer's tree state (completion works with the Files tab never opened). The popup is composer-local: ArrowUp/Down rove, Enter/Tab insert, Escape closes locally (stopPropagation shields the app-level chain — closing a popup must not cancel a round), mousedown-before-blur keeps focus in the textarea. Dropping an Explorer row onto the composer inserts its path at the caret — rows are drag SOURCES (text/plain, the absolute path relativized against the chat's workdir; outside paths stay absolute and let the tool boundary reject them honestly); the tree still accepts no drops. IME-safe: an Enter during composition confirms the candidate, it does not insert.

  • Error rounds gain retry affordances; keyboard shortcuts get a sheet — pane-context errors (services/stream.ts) render as a small danger card with two quiet token controls under the message: Retry walks back to the nearest user bubble and puts that turn's exact text back into the composer via the shared flux:compose bridge — it refills, NEVER auto-sends (the failed round stays in the transcript; stacking a second round on its context is the user's call, per the accepted T-08 split-manually tradeoff), and Copy carries the raw error text. The bare no-chat error (services/dispatch.ts) stays action-free — there is no turn to retry. The shortcuts sheet (? on the bare app surface, or the palette's "Keyboard shortcuts" action) reads its Application rows from the registry's own shortcut fields via listShortcutRows() — chords can never drift from the actions — plus the composer rows (Enter / Shift+Enter / Escape) that belong to no action; typing targets keep their literal ?.

  • Command palette (Ctrl/Cmd+K) — one input over a single action registry (services/commands.ts): every open conversation (switch = the shared selectChat path the sidebar rows use) plus the application verbs (new chat, transcript search, new terminal, toggle sidebar/dock, cycle theme, open settings — plain or directly at the Providers / MCP / Skills section, a one-shot intent the store carries), and reconnect (gated to an actual disconnection). Actions delegate to the owning services — the registry reimplements nothing; availability gates are evaluated per open. The palette is a Radix dialog with real listbox semantics (combobox input + aria-activedescendant, ↑/↓ wrap-around, Enter runs-and-closes, click runs, Escape dismisses through Radix before the app-level chain); its query is one-shot — reopening starts empty, unlike the find bar's remembered search. Two pieces of global state the palette exposed got promoted out of component-local state: the theme CHOICE now lives in the store (services/theme.ts owns what the choice does — the data-theme flip and the live terminals' palette re-read — so a store action never touches the DOM), and the settings dialog's open flag + section intent likewise; the TopBar consumes both from the store and behaves identically.

  • In-transcript search (Ctrl/Cmd+F) — a scoped find over the ACTIVE chat's rendered pane (services/transcript-search.ts + TranscriptSearchBar): cross-node, case-insensitive, non-overlapping matches painted by the CSS Custom Highlight API (::highlight(flux-search[-current]) in stream.css) — the append-only streaming DOM is never touched; engines without the API keep native find (the intercept is gated on the API + an active chat). Streaming appends rescan through a 150ms-debounced MutationObserver that re-counts but never re-scrolls (the view is not yanked by the round's output); Enter/Shift+Enter wrap around; Escape closes the bar BEFORE the round-cancel layer (one surface per Escape, same rule as the mobile drawer); the query survives close/open (find-bar memory); a chat switch closes the bar (search is per-conversation). Scope is the rendered transcript — older tail-paginated history joins the index when loaded. A batched switch+open commit cannot close what it opened: the bar's switch-close effect compares against the service's opened-for chat, not a mount-time ref.

  • The screenshot tour doubles as a visual regression net — npm run shots:update snapshots the tour into a LOCAL, gitignored e2e/.baseline/ (the session's accepted appearance — deliberately not a committed design contract: rendering is machine-coupled and the design stays free to move); npm run shots:check re-tours and pixel-diffs every shot against it, exiting 1 over a 0.2% budget with <name>.diff.png heatmaps in .shots/. Zero extra dependencies: the PNG decode and per-pixel diff run inside the already-launched Chrome (createImageBitmap + OffscreenCanvas, e2e/visual-diff.mjs); captures await document.fonts.ready and phase-lock infinite animations, so same-machine re-runs land 0 px on static shots.

  • The sidebar marks chats with a running round — a global running flag joins the wire: ChatInfo.running (truth = the live task's round state; a task that died mid-round is done-filtered, so no ghost marker). The chats broadcast now also re-fires on ROUND boundaries, not just lease transitions: lifecycle's watcher subscribes the engine's round-state watch (flux-chat's state slot is a tokio::sync::watch — written only on real Idle↔Streaming transitions) and re-broadcasts per flip, plus one final broadcast when a task dies so a marker can never outlive its round. The sidebar row shows an accent spinner before the title for running chats — visible for background chats and rounds driven from OTHER windows (the flag is global truth, not viewer-scoped stream state), ORed with the local streaming flag so this window's own send lights it instantly; the row's accessible name grows ", running".

  • Provider editing (UpdateProvider) — the Providers dialog's entries are no longer remove-only: an Edit affordance on the preview pane (desktop) and each row (mobile) opens a shared edit form that swaps the url / api key BEHIND the fixed id (the id is what chat pins and saved models reference — renaming stays remove + re-add). The api_key field is tri-state over the wire: absent keeps the stored key (it never leaves the server, so there is nothing to resend), present replaces it (empty clears). The url prefills with the effective one; validation is identical to add (unknown id, unknown protocol → inline errors). Pinned chats hot-apply the fresh endpoint: the registry persists first, broadcasts, then re-resolves every pinned chat's instance — the cache syncs and live engines re-begin on the fresh endpoint at their next machine gate (the switch path's carried-pin rebuild), with no provider_switched notice (the pin didn't move). New refresh_chats_pinned_to lifecycle primitive in flux-session; e2e pins the tri-state through the probe's Authorization header.

  • User messages carry a copy affordance — an icon button sitting left of the bubble in the same slot the fork button owns (hover reveal on pointer devices, always visible + 36px tap floor on touch, aria-label="Copy message"). Clicking copies the bubble's exact text at click time (the multi-line pre-wrap content, not a re-rendered form) through the shared copyText path with its plain-HTTP fallback; success flips the icon to an accent check for 1.5s, failure stays silent (copyText already exhausted its fallback — a false "Copied" would be worse than none). Both affordances insert before the bubble body, so the order is canonical (copy · fork · bubble) on the history render AND the live-attach path (attachForkToLiveBubble). lib/dom.ts (buildMsgCopyButton), styles/stream.css.

Changed

  • The two draggable panes now share a conversation floor — the sidebar and the dock each clamped their widths against the viewport ALONE, so on a tablet-sized window (768–1024) they could jointly squeeze the conversation below usable (360 + 520 dragged an 800px viewport's column to −80px), and a dock width persisted from a wide monitor rendered unclamped on a narrow one. core/prefs.ts gains CONVERSATION_MIN_WIDTH (280px — the dock drag's existing promise, now actually enforced behind BOTH panes) with dock-aware clampSidebarWidth / sidebar-aware clampPreviewWidth used by both resizers, and #right-dock's authored CSS width gains a render-time min() guard against 100vw − sidebar − floor — the layer the drag clamp can't be, because it recomputes on every window change, not just on drags. The stored preferences are untouched; only what renders yields.
  • Tool cards speak the flight's outcome — tool results ride the wire as plain strings, so every completion wore the same green "completed" voice, cancelled flights included. dom.ts classifyToolResult now reads the kernel's own string markers into a verdict: the Error: prefix (a tool's Err or a crashed flight) takes the danger rail and keeps full opacity (settled cards fade; failures must not); the [interrupted by user] mark speaks "interrupted" in the warn voice (the user's own action — noticed, not alarmed); a trailing (exit code: N) speaks "exit N" in warn. That last marker is new on the kernel side: flux-tools' format_command_output appends the code for non-zero exits WITH output (the empty-output case already carried it), so a failing command's status is visible without cross-referencing stderr's presence — the model reads it too. Verdicts paint the summary voice only; result text is never rewritten (a successful command echoing "Error: …" is a documented, accepted false-positive face). The fake provider gains a second scripted call (bash exit 3) and ui-check pins the verdict end-to-end (completed · 0.0s | exit 3 · 0.0s).
  • Markdown prose details converged — inline code trades its --fx-inset fill for a fg-based wash: inset sits at bg's luminance in the dark theme, so the chip's fill vanished there; a wash over the current surface lifts in dark / drops in light and reads on whatever it lands on (bg, elev, thinking blocks), no new token. Tables gain even-row zebra washes under the grid — vertical rules stay (a wide horizontally-scrolling table needs its columns) and the stripes add the row tracking the grid alone lacks. Blockquotes gain a recessed fill to partner their strong left rule (a bare rule read as indentation, not a quote); hr steps up to border-strong with a touch more air. The code-block language badge reads muted instead of faint (it sits ON the inset fill, where light-theme faint misses AA — and a language label is information, not garnish); the code copy button's hover lifts its fill to the elevation surface, the same hover language as the message copy button. The e2e fake provider's round-1 reply now carries every prose voice (inline code, fenced block, quote, hr, list) ahead of the pathological pair, so the conversation shots review them all.
  • Text-voice audit across the UI — all 55 text-faint sites classified against the token contract: paths, timestamps, sizes, counters, percentages, placeholders, separators, icons and title-supplemented labels keep the faint voice; the sentences a user must READ — behavioral explanations in the settings panels (MCP apply semantics, skill git-source notes, model-section omission rules, the api-key storage assurance), the provider-switch dialog's apply timing, the terminal status bar's running/connecting lines — rise to text-muted. No token values moved: muted already clears AA on every surface in both themes (the math was re-derived for the audit); a faint-strong token was considered and rejected as redundant.

Removed

  • The right dock's Round tab (round artifacts, F-11) — the whole surface, not a trim. The tab was a derived index over the transcript: file rows duplicated what the stream's write/edit tool cards already show in order, MCP rows jumped to cards that live in that same stream, and shell (bash) rows — a working round runs many commands, each distinct string a truncated label — buried the 2–5 file rows that were the tab's actual payload. Its one unique action (opening a touched file in a preview) is the Explorer's every-day action, one click away on any file including those just written. Gone: the Round · N strip tab and RoundPanel, the roundArtifacts store slice, services/artifacts.ts (fold / dedupe / history-rebuild / reconnect-clear / jump-pulse) with its tests, and the artifact-flash CSS. The dock strip now holds only file tabs and terminal tabs — no dynamic tab churn mid-round; the transcript remains the round's record.

Fixed

  • A mid-batch cancel followed by an engine rebuild no longer duplicates tool results in the provider request. The machine's pending queue carries deferred tool-result voids / the in-flight tool's partial result across a user interrupt (that connection's prefix needs them on the next request) — but an in-place engine REBUILD re-begins its connection over the full persisted transcript, which already carries them, and the stale residue was then written into the request a SECOND time (duplicate tool messages: wasted tokens, broken prompt-prefix caching, strict OpenAI-compatible endpoints may 400). The gate fire (GateReleased) now DISCARDS pending — at that point every message in it is committed and present in the rebuilt prefix — while queued turns started BEFORE the gate still deliver the residue (they run on the pre-rebuild connection, whose prefix lacks it). Pinned by two machine tests: the fire-discard path and the pre-gate delivery path.
  • MCP supervisor: an entry-generation guard closes the remove→re-add respawn race. An old supervisor waking from its backoff sleep identified its entry by id alone, so a remove+re-add of the same id during the window let it adopt the NEW entry: a duplicate session connected over the new supervisor's, the entry's tool_names got clobbered (the duplicate's registrations all collide and skip) and cancel re-pointed at the dead session — a later remove would cancel the wrong session and leak the live one's tools in the registry. Every connect (restore / add / respawn) now mints a monotonic generation paired with BOTH the entry and its supervisor; every entry-mutating step (quit handling, backoff wake, respawn adoption, tools/list_changed notices) re-checks the pair under the lock — a superseded supervisor drops its duplicate session (the dropped handle kills the child) instead of clobbering. The respawn adoption now also registers only after the entry check, so a vanished entry can no longer leak its registrations.
  • SSE tool-call fragments with an absurd index are dropped, not accumulated. The tool-call accumulator sized itself straight from the wire's index field — a hostile or broken upstream naming {"index": 1000000000} in a few-byte JSON fragment allocated billions of entries (the 16 MiB stream cap bounds bytes, not this amplification). Indices at/above a cap (128 — legitimate streams are single digits) are dropped with a warn-once; real calls and the stream continue unaffected.
  • Housekeeping: flux-chat's flight supervisor dropped its write-only interrupted field; flux-tools fixed the one clippy lint (is_some() over a redundant pattern match); buf.rs's module doc now matches the store's upsert semantics.

Install flux-server 0.2.1

Install prebuilt binaries via shell script

curl --proto '=https' --tlsv1.2 -LsSf https://github.com/km0e/flux/releases/download/v0.2.1/flux-server-installer.sh | sh

Install prebuilt binaries via powershell script

powershell -ExecutionPolicy Bypass -c "irm https://github.com/km0e/flux/releases/download/v0.2.1/flux-server-installer.ps1 | iex"

Download flux-server 0.2.1

File Platform Checksum
flux-server-aarch64-apple-darwin.tar.xz Apple Silicon macOS checksum
flux-server-x86_64-apple-darwin.tar.xz Intel macOS checksum
flux-server-x86_64-pc-windows-msvc.zip x64 Windows checksum
flux-server-aarch64-unknown-linux-gnu.tar.xz ARM64 Linux checksum
flux-server-x86_64-unknown-linux-gnu.tar.xz x64 Linux checksum