mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-03 15:12:48 +00:00
bb676ef086ac5d2a69486c33e5a315c2bbbd2610
635
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
bb676ef086 |
Smooth mobile keyboard transitions (#4275)
* perf(app): smooth mobile keyboard transitions Animating dock padding forced Fabric and Yoga to relayout the chat tree on every keyboard frame, producing visible stepping and React list churn. Keep the surface full-height and move it as one translated unit, with scroll range and portal measurements updated only after keyboard motion settles.\n\niOS keeps its native keyboard animation driver behind the same component boundary because its Reanimated signal reports the endpoint at animation start. * fix(app): isolate native keyboard implementation |
||
|
|
ac085d50c6 |
Make import session one tap away and make the sheet find your session (#4216)
* feat(app): make import session a first-class entry point * feat(server): improve import session discovery resilience Search up to 500 persisted candidates per provider and isolate listing failures behind eight-second deadlines so healthy results remain usable. Preserve native persisted title precedence while keeping discovery read-only. Parsing now continues through each transcript so native titles and the true last prompt are retained; the old early exit made lastPromptPreview equal firstPromptPreview. A cheap record prefilter avoids JSON.parse for unrelated lines during the full scan. * feat(app): searchable, grouped import session sheet with per-provider errors The sheet now carries a debounced search field gated on the host's importSessionSearch feature, grows its per-provider page on demand (15 -> 45 -> 90 -> 200) instead of capping at the 15 most recent, and groups unscoped rows by folder under the project name the app knows for that directory. Worktrees of one project would otherwise all head up as the same bare name, so a directory below its project root carries the path under that root beside the name. A workspace-scoped sheet says so and offers "Show all sessions". Importing a row from another directory once that is on drops the workspace from the request, which the daemon would otherwise reject, and routes the user to the agent's own workspace rather than opening a dead tab in the current one. That decision lives in resolveImportTarget: a scoped listing is trusted wholesale because the daemon already matched it realpath-aware, which the client cannot. A provider that fails now gets its own error row with a Retry that refetches only that provider, sourced from both a failed request and the daemon's providerErrors. React Query no longer retries these, so the header spinner settles once every provider has answered or given up (#2512). * test(e2e): import session flow with fixture Claude sessions * fix(app): shorten import sheet scope copy so it fits at phone width At 390px the scoped subtitle rendered "Sessions in this wor..." — the label, its action, and the sheet's refresh and close buttons do not share one line at phone width. The label already sits under the sheet title "Import session", so "Sessions in this" was repeating context the header had: it becomes "This workspace" and the action becomes "Show all". The unscoped label still names the host, which nothing else on the sheet does. * refactor(app): flat recency-ordered import list with per-row folder label Grouping by folder buried recency: a busy directory's block of fifteen pushed a two-minute-old session in a quiet directory below the fold, and the axis a user is scanning on is when they last touched the session, not where it lives. The unscoped sheet is now one list in the order aggregateSessionEntries already produces, and each row carries its folder as a muted third line. resolveDirectoryLabel stays — it is what keeps two worktrees of one project apart — and formatDirectoryLabel renders its two parts as the single ellipsised line a row has room for. A scoped sheet lists one directory, so its rows carry no folder line at all. * fix(app): close import sheet before refreshing sessions Provider-list invalidation waited for every active query before the sheet could close. Mark the disabled picker queries stale after close and navigation so reopening refreshes without delaying the imported agent. * fix(server): reuse workspaces for session imports Host-wide imports should join the oldest active workspace for their directory, or restore its archived workspace, instead of minting duplicates. Rollback remains limited to workspace records created by the failed import. * test(e2e): express the import session journey through a helper DSL * test(e2e): align sidebar journeys with import entry point |
||
|
|
1c960261ac |
feat(providers): add Gajae Code to the ACP provider catalog (#3471)
* feat(providers): add Gajae Code to the ACP provider catalog Gajae Code (gjc) ships a conformance-tested ACP agent (`gjc acp`), so it can be a one-click catalog entry instead of a hand-written custom provider in config.json. Command and env match what the agent's own installer writes. * feat(providers): add Gajae Code icon --------- Co-authored-by: Yeachan-Heo <yeachan.heo@gmail.com> Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com> |
||
|
|
e34aea206e |
feat(plugins): add live timeline rows and client slash commands
Plugins can transform live timeline source items at render time, append durable plugin rows from daemon handlers, and register client-only slash commands in the composer. Related to #3940 and #3952. |
||
|
|
de8535c38b |
Customize top-level sidebar items (#4203)
* feat(app): customize top-level sidebar items Let users reorder and hide built-in and plugin sidebar rows from Appearance settings while keeping the preference device-local. Add Search as a first-class row and standardize section explanations as header info tooltips. * fix(app): preserve consecutive sidebar preference edits Derive each sidebar change from the latest cached settings and retain unavailable plugin slots while editing the visible list. Keep E2E interaction selectors tied to accessible control names. * test(app): isolate provider settings unit boundary Keep the legacy provider-section test focused on provider behavior now that its shared section shell can render the settings info control. |
||
|
|
8dd4da6ee0 |
Keep mobile streaming and panel motion stable under JS stalls (#4190)
* fix(app): activate mobile panels after motion settles Gesture previews were publishing React presentation state and activating retained content before the native motion finished. Keep transient motion on the UI thread and publish the active panel only from the accepted animation completion. * perf(app): re-render only changed history rows Every coalesced stream tick re-rendered all mounted history rows because the inverted FlatList recreates each cell with a new index and ref. Keep layout item identity for unchanged rows and memoize the row on it, and resolve tool-call groups through a stable event so the render callbacks stop churning. * perf(app): count replica row bytes once Budget accounting measured every stored row of a touched host on each persist through the Buffer polyfill. Cache the UTF-8 length per immutable row and count without materialising bytes. * fix(app): settle mobile panels on position Activity now follows the UI-thread position reaching the canonical anchor instead of an animation completion callback. A successful gesture keeps its finishing motion and the matching command adopts it; settlement and command acceptance may arrive in either order. * fix(app): keep settled panel props across JS stalls Turn off Reanimated's FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS. Its settled-props collector drops entries older than two seconds before React receives them, so a JS stall across a settle left the overlay display and backdrop opacity reverted on the next commit while the store held the new panel. * fix(app): reconcile hidden keyboard end on iOS Only a hidden end event resets the keyboard shift. A late visible end no longer resurrects a keyboard that already dismissed. * fix(app): focus the replacement input through autoFocus after clearing Clearing swaps the native input for a fresh instance. Focusing the doomed instance asked Android for the keyboard and then tore the focused view down, which cancelled the show; a JS focus command sent alongside the remount reached a view that was not attached yet and the IME ignored it. Focus and blur requested while the replacement is pending now decide the replacement's autoFocus, which native applies once the view is attached. * fix(app): revise history rows in every viewport The history row memo boundary bails out until its item identity changes, but only the native viewport cloned rows flagged by historyRowRevision. On web and desktop a history host whose tool-call group kept updating from the live head rendered stale. Move the revision into useRevisedHistoryRows and run both viewports' history through it. Regression test renders the web strategy with a memoized row and asserts only the revised host re-renders. |
||
|
|
4a3e6dea63 |
Increase mobile content text to 16px (#4111)
Keep mobile interface chrome at 15px while defaulting readable content to 16px. Migrate existing mobile 15px content preferences once while preserving later explicit choices. |
||
|
|
ed628ff82e |
feat(app): add workspace actions to the command center (#3013)
Eight actions were only reachable through the sidebar workspace menu, the workspace header menu, or a keybind you had to remember: Rename workspace, Copy workspace path, Copy branch name, Pin/Unpin, the three layout toggles, and Show setup. All eight are now in the command center, plus a new one: label assignment. Type a label's name to assign or unassign it on the active workspace without leaving the keyboard. Rename needed more than a palette entry. Its dialog lived inside the sidebar row component, so it disappeared whenever that row was not rendered: a collapsed project or status group, a collapsed Pinned section, or focus mode. It now has a global host keyed on the active workspace selection, mirroring use-global-workspace-pin-action.ts. Each entry reuses the handler the existing menu item already calls, so behaviour matches the menu. Entries that cannot act are omitted rather than present and failing: Copy branch name without a branch, Pin when the host lacks workspacePinning, Show setup when there is nothing to show, and labels when the catalog has not loaded. Toggle Explorer sidebar and Toggle focus mode are registered as workspace contributions, not global ones. Their handlers live in workspace-screen.tsx behind an isRouteFocused gate, so a global registration would have listed two entries that close the palette and do nothing on /settings, /sessions, /schedules and Home. The copy-path, copy-branch, and rename-dialog logic was duplicated across three sidebar files. It moves into one hook and one component so the palette is not a fourth copy. That also fixes drift in sidebar-status-list.tsx, where Copy branch name copied the workspace name instead of the branch and four strings were hardcoded English. This supersedes #3383, which fixes the same bug directly without the shared-hook refactor. Toggle focus mode also had a hidden duplicate: buildPaneContributions already dispatches the same action once split panes are available, so the standalone entry now steps aside there instead of listing the toggle twice. No new i18n keys. |
||
|
|
a8734a9724 | Update project licensing (#3944) | ||
|
|
0ad69d3eb0 |
fix(app): paint conversations immediately after restart (#3907)
* fix(app): paint cached conversations before reconnect Cold startup restored every saved host before publishing any session, so a valid cached chat was blocked behind unrelated directory validation and network-only readiness. Directory and timeline owners now resolve demand through typed storage and authoritative reconciliation while consumers remain source-agnostic. * fix(app): stabilize demand-driven cache restore Create host projections before network availability, route cached workspace and timeline preparation through their owners, and coalesce directory reconciliation per online connection. Update browser cache fixtures for per-agent timeline rows. * fix(app): resume cached timeline through owner * fix(app): surface timeline owner load errors |
||
|
|
46b401eac6 |
fix(app): route workspace actions semantically (#3896)
* fix(app): route workspace actions semantically Separate Explorer visibility from view navigation, and give composer changes and pull request opens semantic routing owners. Unify diff destinations, keep compact PR navigation in Explorer, and derive maximize availability from visible workspace panes. * test(app): cover composer changes routing Update the browser journey for the Explorer-first composer action, then assert the second press follows the shared diff destination. |
||
|
|
e22ad117fd |
Stream assistant text at a steady rate instead of in arrival lumps (#3612)
* Stream assistant text at a steady rate instead of in arrival lumps Assistant text arrived in lumps and was painted that way. The daemon coalesces one message per 60ms window, and that window carries however many characters the model produced in it, which swings by an order of magnitude within a turn. Painting each delta as it landed made the size of those lumps visible. Three changes: - The agent stream coalescer is now leading + trailing. The first delta after an idle window flushes synchronously instead of waiting a full window, matching the invariant TerminalOutputCoalescer already documents. The window stays at 60ms; shrinking it would raise message rate on a loop that already contends with terminal frames and relay encryption, without making the stream smoother. - The client reducer queue commits on a frame instead of an arbitrary 48ms timer, with the timer kept as a ceiling so a hidden tab still advances. - Assistant and reasoning text are revealed at a rate derived from the backlog rather than from arrival timing. Arrival changes the target, not the rate. The store keeps the full text and only the rendered slice is paced, so copy, selection, the chat outline, and scroll geometry all stay consistent with what is on screen. The leading flush means a burst's first chunk lands as its own timeline row. The projection already merges contiguous same-turn rows and clients read the projected timeline, so history is unchanged; the server tests that assert on raw rows were updated for the extra row. Measured on Expo web against a local dev daemon. Mock bursty-stream provider, 6s samples, paint-on-arrival vs paced: frames that advanced the text 55 -> 244, chars-per-frame CV 2.66 -> 1.44, gap p50 100ms -> 17ms. Real Claude Haiku agent over 52s: 84% of frames advance the text, CV 1.29, gap p50 and p95 both 17ms (one frame at 60Hz). Adds a bursty-stream mock model with seeded burst sizes, a gated browser perf spec, and docs/agent-stream-performance.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Address review: safe reveal boundaries and a testable seam Two findings from the PR review. Unicode boundaries. Slicing the revealed text at an arbitrary index could strand a lone surrogate, which paints as a replacement glyph, or split an emoji ZWJ sequence so a family briefly rendered as its members. Both were visible for a frame or two mid-stream — the exact flicker the paced reveal exists to remove. The rendered cut is now pulled back off surrogate pairs, combining marks, variation selectors, skin tone modifiers, and ZWJ joins. The reveal counter stays monotonic and only the painted boundary moves, so a long cluster cannot stall the reveal. Test placement. The hook test used JSDOM, @testing-library renderHook, and monkey-patched requestAnimationFrame, all of which docs/testing.md rules out; it prescribes fixing the missing seam instead. The reveal policy is now a pure state machine in text-reveal.ts (begin/retarget/advance/ complete/visible) covered by plain unit tests with no environment, and the hook is reduced to the frame-clock binding. Rendered behavior is left to the browser spec, which no longer carries frame sampling and statistics in its body — those moved to e2e/support/helpers/stream-smoothness.ts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Measure total painted text, not the tail message The smoothness sampler read the length of the last assistant-message element. A real turn emits many assistant messages, so that element keeps changing identity and its length is not monotonic — handovers read as resets, and a stream that was plainly moving on screen reported almost no growth at all. Sampling the total across every assistant message fixes it. This invalidated the numbers previously recorded for the real-agent run, so the doc table is replaced with a fresh A/B taken with the corrected sampler against a Claude Haiku agent: frames that advanced the text 6% -> 87%, chars-per-frame CV 4.11 -> 1.86, gap p50 317ms -> 17ms, p95 383ms -> 17ms. The paced column also reported zero Unicode defects across a stream containing emoji, which exercises the reveal boundary clamp end to end. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * perf(agent-stream): harden paced text reveal Use standards-compliant grapheme segmentation when available and preserve full arrivals on older runtimes. Cap reveal commits at 60Hz and measure the real browser rendering path so high-refresh displays stay smooth without rendering at hardware frame rate. * Hold back trailing fragments split by arrival boundaries The reveal boundary is not the only place a grapheme gets cut. The daemon's coalescing window ends a delta wherever the window closes, so a caught-up reveal can be handed half a cluster and paint it. Measured on a mixed-script Haiku stream: five frames in ~350 ending in a single regional indicator, i.e. half a flag on screen for a frame. While the turn is streaming, a trailing fragment that cannot stand alone — a dangling high surrogate, a trailing joiner, an odd regional indicator — now waits for the rest of its cluster. Once the turn ends everything received is painted, because nothing more is coming. Complements the grapheme-boundary clamp, which handles the reveal cut; this handles the arrival cut. The same stream now measures one such frame in ~400 rather than five. The remainder is a flush boundary that coincides with the end of an assistant message, putting the two halves in different messages, which the client cannot rejoin — that one belongs in the coalescer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com> |
||
|
|
f73f65cd78 |
feat(app): close settings with Escape (#2828)
* feat(app): close settings with Escape * test(app): cover settings Escape overlay ownership --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> |
||
|
|
c914bcb44b | feat(app): make Explorer a first-class pane host (#3826) | ||
|
|
4724d1d9b7 | fix(settings): preserve valid legacy font fallback | ||
|
|
8fdca94ead |
Fix settings resets across app revisions (#3787)
* fix(settings): preserve settings from newer builds Strict blob validation treated a single newer setting as corruption and then rewrote defaults. Read settings field by field and retain unrecognized data when materializing or migrating known preferences. * refactor(settings): unify discarded-value warnings Emit settings warnings where each stored value is parsed so validation cannot drift from diagnostics. * refactor(settings): validate persisted settings declaratively Use one loose Zod schema with field defaults so mixed-version settings remain loadable without parallel validation paths. * fix(settings): preserve explicit services preferences Keep compatibility fallbacks from overriding explicit settings and prevent read metadata from reaching migration writes. * refactor(settings): derive migration persistence types Let the schema prove normalized settings coverage and carry its persisted shape through migrations. |
||
|
|
c3594b2079 |
fix(app): keep pinned project actions reachable (#3722)
* fix(app): keep pinned project actions reachable Preserve an empty project shell after its final workspace is hoisted into the pinned section so project-scoped workspace creation remains available. Co-authored-by: Agent Op <agentop718@gmail.com> * test(app): verify pinned project workspace context --------- Co-authored-by: Agent Op <agentop718@gmail.com> Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com> |
||
|
|
33cf6cf337 |
perf(app): defer replica persistence until user inactivity
Whole-replica capture and serialization blocked the JS thread during navigation and streaming. Defer that work until the user has been inactive while allowing long-running streams to persist without becoming the idle signal. |
||
|
|
602860625c |
Give readable content its own text size (#3637)
* feat(app): separate content typography from interface size Readable prose benefits from its own scale without inflating navigation and controls. Preserve existing users’ rendered size during migration while giving fresh desktop installs a 15px content default. * test(app): cover independent content typography The appearance journey now verifies that changing interface size leaves content size intact. Timeline traversal yields when a test deliberately holds the requested page, so the helper no longer waits on its own gate. * test(desktop): cover independent content typography The desktop appearance regression follows the renamed Interface control and verifies it does not alter Content size. |
||
|
|
e1805ef542 |
feat(plugins): add theme contributions
Adds plugin-contributed light and dark app themes, including persistence, host-aware coalescing, fallback behavior, documentation, examples, and browser/iOS QA. |
||
|
|
cf4664ddf1 |
feat: add MiniMax Code ACP provider (#3457)
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com> |
||
|
|
19a7d26349 |
feat(app): make the side panel user-directed (#3605)
* feat(app): let the file panel close its tree rail The file panel's tree rail was permanent on desktop — the changes panel had a folder-tree toggle in its toolbar, the file toolbar had nothing, so the rail was there whether you wanted it or not. The changes panel's private DesktopTreeToggle becomes a shared TreeRailToggle so both toolbars render the same affordance; that also drops the treeToggleStyle prop threaded through ChangesToolbarProps, which recomputed exactly what the component already built internally. The i18n keys move out of workspace.git.diff into workspace.tree, since a shared component reaching into the git namespace would rot. The two rails keep independent flags — the file rail in panel-store, the changes rail in useChangesPreferences — so closing one leaves the other alone. FileTreeToggle wires itself to the store rather than taking a callback: the bar renders several components below FilePanel, where the rail actually lives. Both rails also get narrower (320 -> 260 default, 200 -> 180 minimum). The persist version bump resets treeRailWidth on purpose: nearly every existing install carries the old 320 default, so without the reset the narrower rail would only ever reach new users. Anyone who dragged their rail to a deliberate custom width loses it once. Note that MIN_CONTENT_WIDTH still clamps the rail to `containerWidth - 240`, so in a narrow pane the new default never applies — that floor, not the default, is what sets the width there. * feat(app): make the side panel an empty workspace surface The fixed, hideable side panel keeps supporting context beside an agent without seeding content. Pane-local choices stay local, while implicit supporting opens follow a persisted routing preference. * test(app): exercise side panel policy through its capability boundary The browser rail journey already covers the real tree-toggle behavior. Side-panel policy tests now pass their platform capability through the public boundary instead of replacing a production module. * test(app): expect an empty side panel after upgrade * test(app): expect an empty first split pane * test(app): account for the empty side panel in split retention * feat(app): make workspace panel tabs independent Give explicit New actions fresh tab instances, keep panel state opaque to tab management, preserve File navigation context, and stabilize uncached File transitions. * fix(app): preserve file conflicts during preview refresh Keep the current preview mounted for same-target reads and conversions so the editor retains its conflict observation state. Clear retained previews only when the file target changes. |
||
|
|
25fbc939e0 |
feat(app): give composer track pills a segment per state
A mixed fan-out used to collapse the pill to its single most urgent state, so a red dot beside "1 failed" over a child that was still working read as the whole fan-out having stopped. Every state present now gets its own mark and count, in the sidebar's status-group order. |
||
|
|
5731f016c2 |
feat(app): default to steering when sending into a running agent
Steer is the better default for Enter while an agent is running, but flipping DEFAULT_CLIENT_SETTINGS alone would only reach new installs. Client defaults are materialized into storage — the first load writes the whole default blob and every save re-stamps it — so every existing install already has "interrupt" on disk, and a stored "interrupt" cannot be told apart from one the user actually chose. A one-time migration flips them all; picking "interrupt" afterwards sticks. The applied-migration marker sits in its own storage key rather than in the settings blob: StoredAppSettingsSchema is strict and readValidatedJson clears the value when parsing fails, so an unknown field there would wipe every setting for anyone who downgrades. migrateAppSettings owns both of its writes so the settings blob always lands before the marker. A failed marker write leaves the migration to re-run harmlessly; a failed settings write must leave the marker unwritten, or the migration is lost and the user keeps "interrupt" for good. |
||
|
|
c779ef06c5 |
feat(workspaces): add workspace labels (#3510)
* feat(app): workspace labels baseline Existing labels implementation, committed as-is as the baseline for the UI redesign. Server, protocol, catalog store and sync are sound; the UI layer is reworked in the commits that follow. Co-authored-by: Christoph Leiter <christoph@leiter.io> * feat(app): show workspace labels on the sidebar meta line Labels could be assigned and then appeared nowhere. They now draw as a chip on the workspace meta row, after the host, change request, checks and service, and they can be switched off from the Show submenu like any other item on that line. One palette, not two. `baseColors.workspaceLabel` was ten raw Tailwind 500s spanning a 1.9:1 to 4.5:1 contrast range — the exact set identity-colors.ts was written to replace. Label color is now the identity color of the same name, so a chip, a host badge and a CI check on one row sit in one contrast band. `workspace-labels/colors.ts` and its per-file `labelColorViolet` blocks are gone; `chip.tsx` and `swatch.tsx` are the only places a label color becomes ink. A row resolves its labels against its own host's catalog and nothing else. The cross-host projection answers too, but two hosts may give one name two colors and the merge settles that by target precedence and then alphabetical serverId — a row painted in a color its host never assigned is lying about the one thing the color is for. The meta row's doc comment argued against tinted chrome on that line. The chip is a deliberate exception — everything else there reports state and shares the state colors, a label is a name a person chose — so the comment now says that instead of contradicting the code. Alignment: - The chip is exactly as tall as the line: text at the row's 16pt lineHeight, no vertical padding, so a labelled row is the same height as an unlabelled one. - A label run that opens the line hangs left by the chip's own padding, landing the name on the rail the title above it uses. Mid-line the ground stays in the flow. - The swatch row reaches 44pt through hitSlop growing outward from a 20pt swatch, replacing ten 44pt colored blobs in the manager modal. - The picker's raw `<Plus>`, `<CheckSquare>` and `<Square>` go through withUnistyles with module-level mappings, at the menu's 14pt. Also fixes a bug this change exposed: `SidebarRowItemsSchema` is a strict object, so adding an item without a key there fails the whole settings write and silently loses every other Show toggle with it. A test now covers each item in SIDEBAR_ROW_ITEMS. Phase 1 of the workspace labels redesign. The filter menu, the picker's interaction model and the manager modal's layout are later phases. Co-authored-by: Christoph Leiter <christoph@leiter.io> * feat(app): one Labels filter page with tri-state rows Filtering by label cost three levels: Display -> Labels -> Include -> a page of labels, or -> Exclude -> the same page again. It is now one page: every label across every host, one row each, leading with its colour dot. A row cycles off -> include -> exclude -> off. Include is MenuItem's own check; exclude is lucide's X, the check's own pair, at the same 16 in the same trailing slot, so the column does not shift as a row cycles. Exclude also dims the row, because a state you have to identify by its glyph is not one you read at a glance. Unlabelled is a row in the same list, with a hollow ring where the others carry their colour. Match any / all still appears only once two labels are included. Clear is absent, not disabled, when nothing is filtered. Manage labels sits at the bottom below a separator. The whole root row is gone when no host has a label — unless a filter is still active, since the merged catalog only counts online hosts and losing one would otherwise strand a filter that is still hiding workspaces. The page is titled Labels, so its rows stop repeating the word: "Match any", "Match all", "Clear filter". The two Match rows sit directly under the list they qualify and only appear once two labels are included, so the subject is already on screen. All nine locales move together, translated rather than transliterated — Arabic keeps its noun, where "أي" is a determiner that needs one to stay grammatical. SidebarLabelFilter collapses from four collections plus match to one Record<labelKey, "include" | "exclude"> plus match: a tri-state row is one value per label, so include and exclude can no longer disagree about the same name. Unlabelled keys off the empty string, which normalizeWorkspaceLabelName makes unreachable for a real label. toggleSidebarLabelSelection, countSidebarLabelSelections, sidebarLabelSelectionIncludes and deduplicateSidebarLabelNames go with the old shape; setLabelFilter becomes cycleLabelFilter and setLabelMatch, so the store owns its own transitions. No migration: labels are unreleased, so the old persisted shape never shipped, and migrateSidebarViewState already resets on a safeParse failure. The e2e manager step asserted "Red label color" on a swatch that Phase 1 renamed to "Red"; the selector follows the code, and the accessibility.color key behind the old string is deleted from all nine locales as unreferenced. Co-authored-by: Christoph Leiter <christoph@leiter.io> * feat(app): assign labels on the menu's own rails The assign picker was a search box, a create row that unfolded an inline form under it, and hand-rolled label rows whose 44x44 checkbox pushed every dot and name off the rail the rows above them sat on. Nothing lined up with anything. Rows are `MenuItem` now: the colour dot in the 16pt leading slot, assignment as the trailing check the engine already draws for a chosen row. Using the engine's row is the alignment fix; there is no second row implementation to keep in step. Every press toggles and the menu stays open — assigning labels is multi-select, so the dual-target model (`source: "row" | "checkbox"`, `shouldCloseWorkspaceLabelPicker`) is gone along with the model's `close` dependency. You leave by dismissing the menu. Search is deleted. Nobody has enough labels to search, and the field was the thing pushing the list down the surface. `workspaceLabels.search` and `createNamed` go with it across all nine locales; `clearSearch` stays because the manager modal still owns a search field (Phase 4's copy, untouched here). `Create label` moves below a separator at the bottom and opens a page that replaces the list: a name field, the swatch row, one commit row. Picking a colour picks a colour — it no longer creates the label. A failed create keeps the draft and shows the error on the page; a successful one creates, assigns, and returns to the list with the row ticked. Both menus share one page definition. `SidebarWorkspaceMenu` and `SidebarWorkspaceContextMenu` each built the same `pages` memo; they now call `useWorkspaceLabelMenuPages`. Menu engine, all of it forced by putting a text input on a menu page: - `MenuTextField` — the form kit's `FormTextInput` carries a screen's geometry and lands its text 4pt inside the labels above it. This one is drawn as a row's own fill, so the box and the text sit on the row's two rails at once. - `MenuPageDefinition.hoverIntent` — a page you type into is not one the pointer opens or dismisses. Without it the create flyout opened on a pointer passing over the row, and threw the draft away when the mouse drifted off it while the hands were on the keyboard. - The sheet keeps `keyboardBehavior="interactive"`, not `extend`. With `enableDynamicSizing` the largest snap point is the content's own height, so a short page did not grow and the keyboard came up over the field. Caught on an iOS simulator, fixed, and re-shot. - `MenuItem` announces `selected` as `aria-checked`. The deleted checkbox said so; a check drawn and never announced would have been an accessibility regression. Tri-state rows (the filter page's exclude) still cannot announce themselves — menu-wide, still Phase 5's. `sidebar-workspace-row.tsx`'s `isTouchPlatform = platformIsNative || isCompact` is kept deliberately. On a compact web viewport there is no hover, so the row's trailing actions and kebab were unreachable and the labels menu with them; this is the documented `isHovered || isNative || isCompact` shape from CLAUDE.md. Evidence: /tmp/labels-p3-{wide,compact}-{light,dark}-{list,create}.png, the create page on iOS with the keyboard up (/tmp/labels-p3-native-compact-keyboard.png) and the clipped sheet it replaced (-before.png). `packages/app/e2e/browser/workspace-labels.spec.ts` rewritten for the new model, daemon-backed failure coverage kept for both the toggle and the create path, green with the menu unit suites. Co-authored-by: Christoph Leiter <christoph@leiter.io> * feat(app): edit a label in one operation `workspace.label.rename.request` and `workspace.label.recolor.request` become one `workspace.label.update.request { name, newName?, color? }`. Both old RPCs are deleted outright — no COMPAT shim, because the labels feature is unreleased and neither name has ever been on a wire a user owns. The `workspaceLabels` capability flag stays; that one is how a new app detects an old daemon. Two calls could land half-applied, which left the catalog in a state nobody asked for and the UI with nothing true to say. One catalog commit now applies the name, the colour, or both. Renaming onto a name the host already has is rejected rather than merged, and the check runs inside the planner so a refused collision applies neither field. A label keeping its own key is not colliding with itself, so case-only edits pass. The manager modal follows: - The host selector is `HostFilter`, the pill History and Schedules use. `includeAllHost` is now a prop: a surface that edits one host's catalog cannot carry out "all". - Search moves to the sheet header, where it spans the header's own rails. As a body control it capped at the component's 420pt and left a hole on the right whenever a host filter wasn't beside it. `workspaceLabels.clearSearch` goes with the box that had a clear button. - A row no longer opens an editor. It carries a pencil in its trailing slot, hover-revealed on web and always visible on native and compact, on the canonical plain-View hover pattern with the press on an inner Pressable. - The edit view is a name field, the swatch row, and one Save that issues one update. Delete keeps its confirm and its affected-workspace count, and moves to `outline` — red belongs to the dialog, not to the surface that opens it. - Copy says edit, never rename or recolor, in all nine locales. `workspaceLabelErrorMessage` drops the `requestType=… code=…` the daemon client appends to every RPC failure. That is what a log wants, not what a user reads under a name field. `workspaceLabels.accessibility.color` stays deleted: the swatch's accessible name is already the colour's name, so nothing needed it back. Co-authored-by: Christoph Leiter <christoph@leiter.io> * chore(app): converge the labels phases The sweep at the end of the labels redesign: the three items earlier phases found and kept out of their own commits, one cross-phase pass, and the docs. `AdaptiveModalSheet`'s close button reaches its target with `hitSlop` instead of `padding`, which had inset its glyph 8pt from the rail the title, the search field and every trailing glyph below it sit on. This is shared chrome, so it moves the close glyph in every sheet in the app; measured on four of them across both paths — the labels manager desktop and compact, the shortcuts dialog, the rename sheet — and each one's trailing rail now agrees with its leading one. `MenuItem` takes `selected: boolean | "mixed"`. A tri-state row was silent to a screen reader: the check announced itself after the last phase, but exclude had no state at all, so an excluded label was indistinguishable from an untouched one. `"mixed"` is ARIA's own third state, the engine still draws the check only for `true`, and the row keeps supplying its own trailing mark — nothing in the menu engine learns what a label is. CLAUDE.md's platform-gating rules said "NEVER use `onPointerEnter`/ `onPointerLeave`", which is the opposite of what `docs/hover.md` and `constants/platform.ts` both mandate. It now names the real rule — hover on a plain `View`, press on a separate inner `Pressable`, and no `onHoverIn`/ `onHoverOut` on a `Pressable` that contains other pressables — and links the doc that owns it rather than restating it. The cross-phase pass turned up one real defect: the replica cache never learned about labels. `StoredWorkspaceSchema` had no `labels`, so a restored workspace came back unlabelled, and since the directory cursor is current on reconnect the daemon has nothing newer to send — the chips stayed gone for the whole session and the filter matched nothing. An optional field, a serializer line, and an assertion in the restore spec. Everything else measured onto its rails already. Left alone and logged: the per-row `Map` in `useWorkspaceLabelDefinitions`, and the display menu closing when the sidebar swaps to its empty state, which is confined to that swap and predates every phase here. `docs/architecture.md` and `docs/data-model.md` described rename and recolor; they now describe the one update commit, which fields it rewrites, and the per-host resolution a row's chips use. `docs/menus.md` documents the third item state where it already documents the other two. Co-authored-by: Christoph Leiter <christoph@leiter.io> * feat(app): split the labels filter into include and exclude controls The Labels page rotated one row through off -> include -> exclude -> off, which billed the rare operation to the common one: clearing an include cost two presses and routed through exclude, so the sidebar visibly re-filtered into a state nobody asked for on the way out. The row now owns include and a control on its trailing rail owns exclude. Each is one press each way and neither is reached through the other. `cycleLabelFilter` becomes `toggleLabelInclude` and `toggleLabelExclude`; the store's map shape is unchanged, and setting either state clears the other for that label, so including an excluded row is still one press. A row is never both, so the trailing rail holds exactly one 16pt glyph in every state: the check, a permanent `X`, or the same `X` revealed on hover. `X` rather than a circle-slash for the reason Phase 2 settled it — `Ban` fills its box where the check does not, and the column twitches. The control fades rather than mounts and takes its 44pt target from `hitSlop`, so neither revealing it nor changing state moves anything: measured 453-469 on every row, wide and compact. The control carries a tooltip, gated to web where hover exists. It portals into the overlay root above the flyout with no backdrop of its own, and resting on it does not read as the pointer leaving the surface, so the menu stays up. Accessibility: the control is a `checkbox` named "Exclude {label}" with its own checked state, and it stays in the tree at opacity 0 on rows that have no visible mark, so it is reachable without hover. Keyboard focus reveals it, so tabbing to it never means pressing something invisible. The row keeps Phase 5's `selected="mixed"` for exclude. Co-authored-by: Christoph Leiter <christoph@leiter.io> * feat(app): put include and exclude on their own rails on every label row Phase 6 gave the row include and its trailing rail a hover-revealed exclude. The rotation was gone but the awkwardness was not: `X` is the glyph this app dismisses things with, so on a filter row it reads as delete rather than exclude, and one rail with a changing occupant still means the row's trailing area keeps changing identity as you read down the column. Both controls are now on every row in fixed positions, and state is which one is lit. Nothing appears, disappears, swaps places or shifts: the check box is 429-445 and the ban box 453-469 on an included row, an excluded row and an untouched one alike, wide and compact. Because the ban is always there, include -> exclude is one press instead of two. The row still includes, and the check is that same action named. `Ban` rather than `X`. Phase 2 rejected it because its ink fills its 16pt box where a check's does not, so the column twitched when the two swapped on one rail. Nothing swaps here, so what is left is a weight difference: the ban is drawn at 14 inside a 16pt box, which is the visible optical correction available before it stops being a glyph. The box is what the rail is measured from and it stays 16. Lit is `foreground`, unlit `foregroundExtraMuted`. Not `foregroundMuted`, which is the obvious choice and is wrong here specifically: the Match rows further down the same page carry `MenuItem`'s own check in that colour to mean *selected*, so an unlit check in the same ink would say the opposite thing eight pixels above it. Excluded keeps Phase 2's dimmed row. Accessibility: the row keeps `selected` — `true` include, `"mixed"` exclude — and the two buttons carry no checked state of their own, so a label's state is announced once and its two controls read as the actions they are. Both buttons take a tooltip, gated to web with `enabledOnDesktop={isWeb}`, because on non-compact native `TooltipContent` falls back to a `Modal` with its own backdrop. Deleted with it: the `isHovered || isFocused || isNative || isCompact` reveal, the `opacity: 0` mounting, the hover-tracking `View` and the focus plumbing that existed to keep an invisible control reachable. Net -7 lines of code on this row, all of the growth in comments. The store is unchanged; both buttons and the row press map onto `toggleLabelInclude` / `toggleLabelExclude` as they are. The e2e's hover-reveal and `toHaveCount(0)` steps encoded the deleted behaviour and are replaced by a rail measurement taken in every state — relative to the row, since filtering moves the anchor the menu hangs off, and behind a settle poll, since the flyout scales in and a box read mid-animation is not a rail. Co-authored-by: Christoph Leiter <christoph@leiter.io> * feat(app): reveal the label filter's controls, and stop the empty state closing the menu Phase 7 put the check and the ban on every label row at all times, which made a resting list of labels read as two columns of buttons with a filter somewhere behind them. A row now shows its own state and nothing else — a marked row its mark, an untouched row nothing — and hovering any row brings both controls up, the mark you have lit and the other one unlit beside it. So the column you read down is the filter, and the row you are pointing at is the one offering to change it. Nothing about the rails changed. The two boxes are laid out in every state and only their ink comes and goes: check 429-445, ban 453-469 on an included row, an excluded row, an untouched one, hovered or not, wide and compact. The reveal is opacity, never mounting, so it cannot move the row out from under the pointer that fired it. Hover is tracked on a plain `View` wrapping the `MenuItem` with the presses on the buttons inside it, since `MenuItem` is itself a `Pressable` (docs/hover.md); each control counts its own keyboard focus as revealed, so tabbing to one never means pressing something invisible; and `pointerEvents` follows visibility so a hidden control cannot be clicked. On native and compact there is no pointer, so both simply stand there. Everything Phase 7 settled is kept: `Ban` at 14 in a 16pt box, `foregroundExtraMuted` for unlit against `foregroundMuted` meaning *selected* eight pixels below, `spacing[2]` between the two with `hitSlop` tiling the gap, the row owning `aria-checked` while the buttons stay plain named actions, and both tooltips gated `enabledOnDesktop={isWeb}`. --- Excluding a label that empties the sidebar closed the display menu. Logged since Phase 2, narrowed in Phase 5 to the empty-state swap, owned by nobody. `SidebarLabelFilterEmptyState` was a component rendered *instead of* the list, with its own container and scroll view, and both branches were handed the same header element. Different subtrees, so React unmounted the header on the swap — and the header holds the display menu's trigger, so the popover went with its anchor. The empty state is now a body: both list modes render it where their groups would go, inside the scroll they already own and below the header they already render, and the swap can no longer reach anything above it. Cleared from that same still-open page, the way back out is the swap in reverse and it holds too. `ProjectModeList` was already the right shape — its "no projects yet" body sits below the header — but its header was conditional on `unpinnedProjects.length > 0 || hasActiveHostFilter`, which is the same bug written as a guard: keep the header when there is a filter to undo. The label filter joins that condition rather than getting a second mechanism. Both empty states move into `sidebar/empty-states.tsx` so the two list modes can render either without importing each other, which also leaves the card's geometry defined once instead of once per body. Still conditional, and left alone: a sidebar with no projects and no filter has no header at all, so clearing the last host filter on an empty sidebar closes the menu the same way. That one is the onboarding empty state, where a section heading over "Add a project" would be a heading over nothing, and no menu is reachable in the state it lands in. The e2e gets the step that would have caught this: exclude into the empty state, assert the page you pressed on is still up, clear from it, assert the same. Verified by reverting both list files to Phase 7 and watching it fail on "element(s) not found". Phase 7's rail assertions are kept as they are — offsets within the row, behind a settle poll — and joined by computed-opacity reads for the four states of the reveal. Evidence: /tmp/labels-qa-p8/, 28 shots plus INDEX.md — the resting list, a hovered row, an included row with the pointer away, and the empty state with the menu still open over it, at wide and compact in both schemes. Co-authored-by: Christoph Leiter <christoph@leiter.io> * feat(app): make the label filter include-only Phases 2, 6, 7 and 8 each tried to give exclusion a comfortable shape inside a 232pt menu row: tri-state rotation, a hover-revealed control, two permanent buttons, two hover-or-lit buttons. Every fix bought a new awkwardness, because two controls in one menu row is a lot of machinery for a filter. The label filter is now the host filter — one tap in, one tap out — and nobody has ever complained about that one. A row is a plain `MenuItem` with the colour dot leading and the engine's own check trailing. Nothing else. Which makes the alignment law trivial to satisfy, and that is the point. Deleted rather than disabled, since 6, 7 and 8 are in the branch history if exclusion ever comes back: both trailing controls, the hover-tracking `View`, the `opacity: 0` mounting, the focus/blur plumbing, the `pointerEvents` gating, both tooltips and their `enabledOnDesktop={isWeb}` gates, the lit/unlit token pair, `Ban`, and the two rails they held. `SidebarLabelFilter` collapses onto the shape of `hostFilters` sitting three fields above it in the same store: `labels: string[]` plus `match`, one `toggleLabelFilter`, one `clearLabelFilter`. The exclude arm of `filterWorkspacesByLabels` goes with it, and `filter.include` / `includeLabel` / `exclude` / `excludeLabel` leave all nine locales. Persisted version 4 with no migration — the record shape never shipped, and `createValidatedPersistStorage` drops state that fails the current schema before `migrate` ever runs. Two things this leaves unused, both deleted: - `MenuItem`'s `selected: boolean | "mixed"`, added in Phase 5 so a tri-state row could announce itself. The filter row was the only caller, so the union collapses back to `boolean` and the two `selected === true` guards that existed for `"mixed"` go back to plain truthiness. - The `docs/menus.md` paragraphs from Phases 7 and 8 on hover-revealing a pressable trailing control inside a menu row. No caller does that any more, so the doc was describing a pattern the codebase no longer has. Kept: `SIDEBAR_UNLABELLED_LABEL_KEY`, because `Unlabelled` is still a row and now includes like any other; `Match any` / `Match all`, because multi-select include still means two different things. Phase 8's empty-state fix is untouched and still holds — `hasActiveSidebarLabelFilter` is what the header's survival condition reads, and it answers the same question against the new shape. Evidence: /tmp/labels-qa-p9/, 12 shots plus INDEX.md — resting, two selected, and the empty state with the menu still open over it, at wide and compact in both schemes. Co-authored-by: Christoph Leiter <christoph@leiter.io> * fix(app): give label-grouped rows their project icons, and label groups a tag Grouping the sidebar by label rendered every workspace row with its placeholder initial. The same workspace under status or project grouping showed its real project icon. The components were reused; the data feeding them was not. One component served both grouping modes and threaded the icon map through correctly. Fourteen lines above it, a second `useProjectIcons` call was gated on `groupMode === "status"`, written when status was the only mode that put icons on rows. Phase 2 extended the render branch to `status || label` and left the gate alone. Nothing forces a render branch and the data it renders to agree, and these two were far enough apart to drift in silence. Reuse buys you one implementation of the drawing; it buys you nothing about who decides what to fetch. So the fix is the shape, not the line. There is now one `useProjectIcons` call for every mode, and its targets come from `buildSidebarProjection` — the same function that produced the rows, from the same `projects` list. A mode can only render a row for a project the projection already knows about, so the map cannot disagree with the screen. The gate is gone rather than extended: extending it would leave the identical trap for the next grouping mode. `useProjectIcons` dedupes by `serverId:projectId` and caches through `useQueries`, so the gate was a premature optimization that bought a bug. Pinned by `sidebar-projection.test.ts`: for each of the three modes, every project view key the projection will render — pinned rows, grouped rows, project headers and the rows under them — has an icon target. Red on the old gate: restoring `groupMode === "status" ? … : []` in its new home fails project and label grouping with `['project', 'other-project']` uncovered, and status passes, which is exactly the bug's signature. The sibling sweep over everything `SidebarStatusModeWrapper` is handed found one more of the same shape, correct today: the projection chose its shortcut sections with `status || label`, so a fourth mode would have silently walked the project rows. Both the groups and the sections now come from one exhaustive `switch` on the mode, which makes the next mode a compile error rather than an empty list. `workspaceEntriesByKey` is already written the safe way round (`!== "project"`), and the rest — host badges, pinning support, the empty-filter state — never read the mode at all. Two names moved with the shape: `SidebarStatusModeWrapper` is `SidebarGroupedModeList`, and its branch is `groupMode !== "project"`, so a new mode lands in the grouped list instead of the project list's `else`. Also: label group headers draw lucide `Tag` instead of `CircleDot`, in the label's colour, muted for `Unlabelled` — the glyph the Labels menu and the workspace menu already use for this feature. It keeps `StatusGroupIcon`'s 14 with no optical nudge, and that is measured rather than assumed: in the header's leading slot both ink a 14x14 box at the same offset, and the tag lays down 76 ink pixels to the circle's 84, so reaching the corners of its box costs it no weight beside the status glyphs. QA: /tmp/labels-qa-p10/ — wide and compact, light and dark, label grouping and the same workspaces under status grouping, plus the hover chevron swap, the glyph zooms, and a before/after pair taken with the fix reverted to the bug's shape. Co-authored-by: Christoph Leiter <christoph@leiter.io> * fix(app): align label chip typography Co-authored-by: Christoph Leiter <christoph@leiter.io> * docs: format architecture module table Co-authored-by: Christoph Leiter <christoph@leiter.io> * fix(app): narrow workspace labels workflow Co-authored-by: Christoph Leiter <christoph@leiter.io> * fix(app): mirror bottom sheet test exports Co-authored-by: Christoph Leiter <christoph@leiter.io> * test(desktop): assert project browse by destination Co-authored-by: Christoph Leiter <christoph@leiter.io> * fix(app): retain empty projects without label filters Co-authored-by: Christoph Leiter <christoph@leiter.io> * fix(app): preserve status grouping test contract Co-authored-by: Christoph Leiter <christoph@leiter.io> * fix(app): retain filters after label load failures Co-authored-by: Christoph Leiter <christoph@leiter.io> --------- Co-authored-by: Christoph Leiter <christoph@leiter.io> |
||
|
|
0f2e58d4bb |
fix(app): back off and offer retry when timeline sync is refused
A host can refuse a timeline read for as long as it likes. Codex does this whenever another writer holds the thread: every catch-up fails with "already has an active writer" until the other session lets go. The app retried that on a fixed 1s timer with no ceiling, so an idle window sat there issuing a request and logging a warning every second, indefinitely. The refusal is normal lifecycle; the traffic was the bug. Catch-up and subscription retries now double from 1s to a 30s ceiling, resetting on success, reconnect, delivery-mode change, and visibility change so recovery stays immediate once the condition clears. Backing off alone would leave the user waiting up to 30s with no way in, so both failure surfaces get a retry they can press. Background retries stay silent; a retry the user asked for is a fallible user action and owns its pending state, so "retrying" is a status the sync model publishes rather than a React boolean. The blocking "Failed to load agent" screen was the worse half: its load effect self-gates on the error state, so a refused first resume was terminal until the user navigated away. Its retry resets the state to idle, which re-arms the effect. |
||
|
|
5ec26f0c09 |
feat(app): align interface typography to base size
Treat the appearance setting as the rendered base size so the type ramp scales predictably. Native starts one pixel larger while web and desktop preserve the authored 14px base, with legacy values migrated on load. |
||
|
|
60bafad3ee |
feat(app): make the New tab launcher keyboard-first
Cmd/Ctrl+T now opens the focused pane's launcher, while explicit target shortcuts bypass the menu. Dispatcher ownership is scoped to the app tree so retained workspaces cannot intercept commands. |
||
|
|
a54b6c2020 | fix(app): retain Explorer tab state | ||
|
|
75adf53893 |
feat(app): polish workspace tabs and changes
Size tabs from rendered labels and switch to horizontal overflow once minimum click targets are reached, keeping the add-tab action adjacent until it must remain fixed. Make Changes an always-expanded document with an optional desktop navigation tree, keep Git actions in their platform-owned headers, remove pinned and inline split shortcuts, and place review tabs beside Changes. |
||
|
|
8c4e54eace |
perf(app): keep composer typing within the frame budget (#3450)
* perf(app): keep composer typing responsive The controlled web textarea pushed every keystroke through the draft store and broad composer tree before paint. Keep live text in the focused DOM input and publish draft and selection state after paint so persistence and reactive features remain intact without blocking the typed character. * fix(app): preserve live composer state at actions * fix(app): use live input for autocomplete clicks * fix(app): keep submit activity atomic |
||
|
|
42245d139a |
Add active-turn steering (#3394)
* feat(agents): add active-turn steering Let users choose whether an immediate prompt interrupts or joins the active turn. The daemon owns exact-turn admission, provider delivery, safe fallback, and canonical timeline identity while queueing remains client-side. * fix(agents): stabilize steering history and replay QA * fix(agents): preserve steering timeline order * fix(agents): reconcile resumed provider history * fix(agents): serialize steering with cancellation * test(agents): prove Claude multi-tool steering * test(agents): force explicit history hydration * fix(agents): flush output before steer admission |
||
|
|
d60d92e909 |
feat(app): show workspace identity badges (#3445)
Let users opt into branch and project context on workspace rows while keeping the default sidebar quiet. Project names follow the existing project-icon visibility rule so grouped rows do not repeat their parent. |
||
|
|
38f176595e | Simplify mobile agent configuration (#3424) | ||
|
|
f0bd2c8483 |
Resume cached timelines without replaying their tail (#3329)
* perf(timeline): resume cached history from its exact range Warm launches were repainting cached history and then downloading the same bounded tail to rediscover that nothing changed. Persist synchronization authority only when the complete current canonical window can be encoded losslessly; otherwise retain the existing display-only fallback. * test(timeline): hide replica setup behind E2E helper * fix(timeline): bound persisted resume overflow * test(timeline): cover workspace eviction catch-up order * test(timeline): read persisted range from IndexedDB |
||
|
|
b03dc804eb |
feat(app): make pinned workspaces sortable (#3341)
Reuse the existing workspace drag interaction for pinned rows and persist their local order across grouping modes. Keep pressed and dragged row scrims aligned with the active backdrop, and preserve memoized row rendering on web. |
||
|
|
373a98c64d |
Restore host directories instantly before reconnect (#3259)
* feat(sync): restore host directories before reconnect Persist complete project, workspace, and active-agent replicas so every registered host can paint immediately, including offline hosts. Reconcile those replicas through monotonic per-entity cursors on the existing directory RPCs without retaining an event journal. * fix(sync): preserve active turn identity during catch-up Own Agent-to-wire projection beside the existing snapshot normalizer so replica persistence does not reach through directory-sync internals. This also keeps identified active turns intact when unchanged agents are folded into an incremental directory response. * test(e2e): inspect the real IndexedDB replica Keep cache setup, reload, and hydration coverage on the production browser storage backend after the replica moved off localStorage. Measure writes at the actual IndexedDB put boundary as well. * fix warm reconnect cache paths * feat(sync): persist project icons and deepen directory sync * fix(sync): preserve project update order * fix(cache): preserve validated replica persistence * fix(sync): retain cached agents during catch-up |
||
|
|
9c195feb9b |
Keep agent profile settings valid across providers (#3331)
* fix(app): keep profile modes scoped to providers Applying a profile through sequential field setters allowed a provider change to race mode persistence, poisoning modeless provider preferences with another provider's mode. Apply profiles as one form transition and heal invalid provider-scoped mode preferences when profiles are used. * test(app): cover profile features across providers |
||
|
|
36312352db |
fix(app): validate persisted client state (#3289)
* fix(app): validate persisted client state Persisted replica data could satisfy TypeScript through an assertion while missing fields required by renderers. Treat every AsyncStorage value as untrusted input, clear values that fail strict schemas, and version the replica DTO so incompatible caches cannot hydrate. * fix(app): preserve validated legacy state * fix(app): preserve validated preferences Preserve supported legacy location fields and every shipped theme while keeping unknown persisted fields rejected. Align browser test seeds with the current preference shape so strict validation exercises the intended provider. * fix(app): migrate legacy agent preferences Released clients stored host/location fields and per-provider thinkingOptionId together. Migrate those exact strict bytes into current preferences without admitting unknown fields. * fix(app): preserve drafts with legacy reviews Accept the exact strict shape released for workspace review attachments so migration can discard that obsolete attachment without clearing every persisted draft. |
||
|
|
308e9af845 |
Add reusable agent profiles (#3208)
* Search models across all providers from the picker root * Store agent profiles in daemon config and apply agent config in one RPC An agent profile is a named bundle of provider, model, mode, thinking option, feature values and notes, stored host-wide next to terminal profiles. It carries no system prompt: AgentSessionConfig.systemPrompt is creation-only, so a profile holding one would apply when starting an agent and silently do nothing when applied to a running one. Applying a profile through the four single-field RPCs can fail halfway and leave an agent matching no profile, so agent.config.apply takes the whole bundle. Model runs first because a thinking option only means anything against the model that offers it. Old daemons parse their persisted config strictly and would drop an unknown agentProfiles key, making a save appear to succeed and vanish, so both capabilities are advertised on server_info for the client to gate on. list_profiles exposes the profiles to orchestrating agents, which read the notes to pick one and copy its values into create_agent. * Add the agent profiles settings section Profiles live behind one module boundary so removing the feature is a directory plus one mount, which is what favourites never was. The picker and composer will ask for rows and an apply function rather than learning what a profile contains. The edit form follows the form-model pattern instead of the terminal profile modal's per-field state, because provider selection cascades into which models, modes, thinking levels and features exist below it. The section is gated on server_info.features.agentProfiles: an older daemon drops the key on write, and a save that appears to work and then vanishes is worse than an unavailable state. * Document agent profiles and name the four senses of profile The glossary reserved Profile for HostProfile, terminal profiles owned the user-facing word, and custom-providers.md used it for provider aliases. Agent profile is now the term, with all four cross-referenced. data-model.md never documented terminalProfiles either, so both profile lists are backfilled together along with the whole-list patch semantics and why absent and empty differ. * Pin agent profiles to the model picker and delete favourites Applying a profile writes its values into the agent controls and is then forgotten, so rows carry no checkmark and no selected state: after applying, nothing is selected. Chevron still means navigate deeper, so the absence of one is what marks a row as an action. A running agent is its provider's process and agent.config.apply carries no provider, so the live picker only offers profiles matching the agent's provider rather than showing rows that cannot do what they say. The draft path has no such limit and switches provider freely. Draft feature values go through preferences rather than the feature callback alone, because the draft feature hook clears local values whenever the provider changes and a provider-switching profile would lose them. Favourites are deleted rather than migrated: they were device-local AsyncStorage keyed by provider and model, while profiles are host-wide daemon config. ProviderSelectionModelRow.favoriteKey stays as row identity and moves to its real owner; renaming it is its own commit. * Cover agent profiles and cross-provider search with E2E specs Model rows expressed selection only as a checkmark glyph, which assistive tech cannot see and a spec cannot assert. Rows that draw the indicator now carry aria-selected; profile rows deliberately carry none, because a profile is an action rather than a selection. That is what makes "nothing is marked selected after applying a profile" a real assertion instead of a tautology, with the materialized model one level down as the control. The search fixture extends claude rather than mock: provider-config.ts only allows the six shipped providers as extends targets, so a derived provider cannot inherit the dev-only mock provider. * State what applying a config bundle does on partial failure applyBundle is not a transaction and the earlier commit message implied it was. A step that rejects aborts the rest and the steps that already applied stay applied, which a profile omitting modelId can reach: provider setters validate against the agent's live model, so mode can land while a feature that model does not support is rejected. Nothing is hidden by that. Every setter emits agent state before returning, so what applied is streamed to the client next to the rejected response. Rolling back would re-invoke the setters that just failed, usually against a session that is gone, and pre-flight validation would restate each provider's model-dependent checks where they would drift from the setters that own them. What sending the bundle in one request removes is the multi-round-trip failure class: interruption between hops, and interleaving with another client. The test already pinned this behaviour; its name now says so. * feat(agent-profiles): refine profile discovery and appearance Make profiles discoverable from empty and provider-scoped model pickers, keep global search stable and virtualized, and move profile settings under Agents. Replace emoji identity with a shared Lucide icon and host-color picker, while requiring concrete provider selections so profiles materialize predictably. * fix(agent-profiles): defer profile swatch styles Resolving Unistyles proxies at module load can freeze the temporary startup color scheme before the persisted theme is restored. Keep only style-key names at module scope and resolve the selected swatch during render. |
||
|
|
8c003351d0 |
fix(app): keep theme selection consistent (#3214)
Theme registration, the appearance picker, persistence, and shortcut cycling drifted as new choices were added. Make one ordered catalog own those paths so Auto and Pure Black participate consistently, while lifting the Pure Black selected-workspace surface above its sidebar. |
||
|
|
6aa9f92156 |
Stop notifications after removing a host (#3176)
* fix(notifications): stop pushes after host removal Push tokens were persisted indefinitely with no relationship to app pairing state. Give registrations a 48-hour renewable lease so offline unpairing converges, and revoke immediately when the host is reachable. * fix(notifications): make token revocation bounded and durable |
||
|
|
c5d5441c9c |
Preserve dictation recordings across WebSocket interruptions (#3159)
* fix(app): preserve dictation audio across transport failures Keep captured PCM in the existing local segment buffer when a WebSocket send fails. Finish only after server acknowledgements and fail an idle drain so the retry path can resend the complete recording. * fix(app): dispose dictation stream sender cleanly |
||
|
|
ca2b633286 |
feat: add file manager context actions (#3027)
* feat(app): add file manager context actions * fix: reshape file context actions --------- Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com> |
||
|
|
a98d154b6b |
Search workspace files from the command center (#3059)
* feat(command-center): add workspace file search Use the existing workspace-aware fuzzy search so file lookup follows the file explorer's ignore and hidden-directory rules. Cmd/Ctrl+P opens a removable Files scope while the normal command center also includes file matches. * fix(command-center): stabilize file search results * fix(shortcuts): preserve project picker command * fix(command-center): keep file results while searching * feat(command-center): integrate the files scope into the search row The scope was a 24px pill with 12px text sitting inside the 16px input line, so it read as a badge dropped into the query rather than the scope of the search. Make it a leading cell of the search row instead: folder icon, input-sized label, and a hairline divider before the caret. It stretches to the row's cross size and bleeds through the header padding with negative margins, so its height is driven entirely by the input and the panel's own radius clips its corner. Toggling the scope cannot resize the header, which the new spec pins. * fix(command-center): restore the files scope as a chip The previous commit turned the scope into a flush leading cell of the header. Put the chip back: rounded, bordered, folder icon and 14px label, no divider. It stretches to the row instead of carrying a height, so it is exactly as tall as the input's line box and cannot resize the header. * refactor(shortcuts): regroup and reorder the shortcut help sections Panels was a junk drawer: the command center, file search, the shortcuts sheet itself, new terminal, the sidebars, settings, focus mode and theme cycling. The two shortcuts people reach for most, Cmd+K and Cmd+P, sat inside it below three other sections. Navigation held both workspace and tab movement. Sections are now General, Projects & Workspaces, Tabs & Panes, Layout, and Agent Input. General leads with the command center and file search. Tab movement joins Tabs & Panes, new terminal joins the tabs it creates, and Layout keeps only the sidebar and focus toggles. Row order is now data rather than a side effect of binding-definition order, so the row someone opens the sheet to find sits at the top of its section. A test fails when a new shortcut has no place in that order instead of letting it sink to the bottom unnoticed. * Keep file search failures in view |
||
|
|
90aa8bbefc |
feat(shortcuts): allow shortcuts to be unassigned (#2510)
Allow users to clear and restore shortcut bindings, persist the explicit unassigned state, and keep shortcut displays and actions aligned with the binding that fires. Includes focused unit coverage and desktop renderer Playwright coverage. |
||
|
|
1e0ce69c54 |
Make the sidebar resize handle usable by touch (#2953)
* fix(app): make the sidebar resize handle usable by touch The drag handle was a 10px strip at `right: -5`, so half of it sat outside the sidebar's bounds. Web hit-testing does not clip to the parent box, but iOS `hitTest:` and Gesture Handler's Android traversal both do, leaving a ~5px target with no visible affordance — hover is the only cue, and hover never fires on native. Split the handle by input capability rather than by platform: a fine pointer keeps the hairline straddling the border, and touch gets a 24x88 grab target that sits fully inside the sidebar with a permanent grip. The pan gesture now requires horizontal intent and fails on vertical drift, so a finger on the grip still scrolls the list behind it. `useHasFinePointer` replaces the copy of the same media query that lived in the review surface. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(app): simplify touch resize handle styles * fix(app): reveal touch resize grip while pressed Keep the touch grip out of sidebar content while idle, then expose it for the full resize gesture lifecycle so it remains visible during an active drag. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com> |
||
|
|
b9b3a24d58 |
fix(desktop): restore notification sounds (#2582)
Restore desktop notification sounds by default, add a persisted Play sound setting, and group notification permission and testing in a dedicated Notifications section. |
||
|
|
b167ee02e1 |
feat(app): search history by workspace, agent, and branch (#2995)
History was a date-grouped list you scrolled. Finding a session from last week meant eyeballing every row. This adds a search field to the existing filter rail, next to the host picker. Search runs on the daemon, not the client. The daemon already holds every persisted agent in memory, so it ranks the whole history and the client never has to warn that it only looked at the pages it had loaded. A searched response is one ranked page with no cursor. The global top K is always contained in the union of each host's own top K, so merging per-host pages and slicing back to the limit is the exact global answer — no k-way merge, and no offset into a ranking that is recomputed per request, which would duplicate and skip rows as history changes underneath it. Past result 200 of a relevance order the answer is a narrower query, so the footer says that instead of offering a "Load more" that cannot deliver. Matching is the app's existing picker matcher, moved to the protocol package so app and daemon share one implementation instead of drifting into two "why didn't this show up?" rule sets. Typo tolerance is opt-in, so the comboboxes keep exact narrowing; the budget is per token, and four-character tokens get transpositions only, because at that length a free substitution turns "main" into "mail", "maid" and "rain". Ranked rows carry the spans they matched on, so results can mark them. The subsequence and typo tiers match characters the eye would never find unaided, and an unexplained row reads as a bug. The ranker computes the spans anyway; sending them keeps the client from deriving a second opinion that could disagree with the ranking it is explaining. Hosts that fail or are offline are now named above the list. Without that, "No sessions match" is a claim the app has no basis for. Gated on server_info.features.agentHistorySearch, checked once against the connected hosts. Reachability is reported by the banner instead, so one offline host in the registry no longer hides the field. |
||
|
|
2298e9efdf |
Fix composer toolbar collapse flicker on tab switch (#2937)
Retained tabs stay mounted under display:none while inactive, which reports a zero-width onLayout. ControlledAgentControls fed that straight into its density resolver, so a hidden tab's toolbar collapsed to its tightest density and painted one frame collapsed before re-expanding when the tab became visible again. Extract the zero-width guard that useContainerWidthBelow already had into a shared readMeasuredWidth helper, and use it in the composer toolbar's own layout handler so a display:none measurement can never drive its density. Also bump the chat outline rail's MIN_PANEL_WIDTH breakpoint (818 -> 918) so the rail disappears earlier under a narrower panel, and widen the chat-outline e2e spec's viewport so its panel keeps healthy margin over the new threshold. |