* refactor(layout): swap pane width caps for viewport-proportional runtime cap
Replace the hard 350px sidebar / 450px right caps with
max(800, viewport*0.7) so wide displays get a roomy file/terminal
panel and the sidebar can grow with the screen. Pinned minimum is
raised from 50px to 180px so panels don't collapse into unusable slivers.
Caps now live in lib/state/layout-manager/caps.ts; constants module
no longer exports per-column max-px constants.
* feat(layout): persist preferred pane widths, flush on unload, roomier tablet split
- Mirror sidebar / right widths into a sessionStorage pinned-defaults
slot on every resize; seed brand-new task envs with those widths so
the user's last-set sizes follow them across tasks.
- Re-apply the runtime cap to every pinned group on each ResizeObserver
tick so a panel pinned to the old cap grows when the user widens the
window (and re-clamps when they shrink it).
- Flush the debounced layout save on `beforeunload`, so a reload less
than 300ms after the last drag doesn't lose the new width.
- Drop the tablet right-panel inner split minSize from 30/20 to 15/15
so the files vs. terminal proportions match the loosened desktop feel.
* test(e2e): cover pane resize, persistence, and edge cases
Four spec files under apps/web/e2e/tests/layout/ exercise the runtime
cap and pinned-defaults persistence end-to-end via dockview's `.dv-sash`
drag handles and `window.__dockviewApi__`:
- pane-resize-right: drag past the old 450px cap, viewport-shrink
re-clamp, reload + cross-env propagation of preferred width.
- pane-resize-sidebar: same coverage for the sidebar including
toggle-off/on width retention.
- pane-persistence-tablet: localStorage round-trip for the tablet
layout keys (`task-layout-tablet-v1`, `task-layout-right-v2`).
- pane-resize-edge-cases: double-click smoke, rapid drags +
beforeunload flush, maximize round-trip, sash off-viewport, resize
with sidebar hidden.
Adds e2e/helpers/dockview-resize.ts for the shared drag + width
read-out helpers.
* fix(layout): cap sidebar at 30% screen width, preserve legacy initial widths
- Split the runtime cap into a sidebar-specific function (max(350, vw*0.3))
and the right-pane function (max(800, vw*0.7)). File-tree / task-list
content rarely benefits from more than ~30% of the screen.
- Preserve legacy initial widths: getPinnedWidth now clamps the ratio-based
default to the old hard limits (350 sidebar, 450 right) so a fresh task
env opens at exactly the same width as before. Users can still drag past
these via setConstraints — only the *initial* default is capped tightly.
* fix(layout): address PR review — narrow viewport, hidden sidebar, e2e flake
Bot-review fixups for #1005:
- caps: bound runtime caps by `viewport - VIEWPORT_RESERVE_PX` so the
800px right-cap floor can't collapse the center column on narrow
screens (Cubic P1).
- applier: respect `column.maxWidth` on the sidebar too, so the
`compact` preset's 260px cap is honored at runtime (Cubic P2).
- dockview-store: skip the pinned-defaults mirror when the column is
hidden — auto-sync infers sidebar from grid index 0, which is the
center when sidebar is hidden, so an unguarded mirror would
persist the center's width as the sidebar's (Greptile/Cubic P1).
- trackPinnedWidths: gate the auto-sync on `sidebarVisible` /
`rightPanelsVisible` for the same reason.
- sizing: use `LAYOUT_RIGHT_RATIO` for non-sidebar columns instead of
silently reusing the sidebar ratio (Greptile P2).
- e2e: tablet viewport bumped from 1024 → 700 (≥1024 is desktop, not
tablet — the old viewport never rendered the tablet layout and
every assertion timed out). Tests now also verify rendered panel
proportions, not just the localStorage echo (CodeRabbit #2/#3).
- e2e helpers: throw on missing `.dv-sash` instead of returning `-1`
(CodeRabbit #1); shared `openWideTask` extracted from three spec
files (CodeRabbit #5); silent `return` in evaluate blocks
replaced with `throw` for fail-fast (CodeRabbit #4).
* fix(layout): swap drag-mouse e2e for programmatic resize + claude/cr followups
Round 2 of PR review fixups:
- e2e: convert pane-resize-right/sidebar/edge-cases specs from
`page.mouse` drag-by-delta to a programmatic `splitview.resizeView()`
call. The mouse-drag approach was flaky in headless CI (5 chromium
failures across shards 2-4); the programmatic path tests the same
cap-enforcement and persistence contract without depending on
pointer-events targeting through a 1px .dv-sash overlay.
- dockview-layout-setup: remove an unreachable guard in
trackPinnedWidths — `rightIdx === 0 && sidebarVisible` cannot occur
given the `sv.length < 2` early return (Claude).
- dockview-layout-setup: cancel any in-flight debounce timer in the
setupLayoutPersistence disposer so a fire can't race with teardown
and persist stale layout (CodeRabbit).
- constants: document why LAYOUT_SIDEBAR_RATIO and LAYOUT_RIGHT_RATIO
are both 0.25 today — separate constants stay decoupled so a future
tweak to one doesn't silently change the other (Claude).
* fix(layout): drop cross-env width propagation — defaults stay unchanged
User feedback: the initial sidebar width on a brand-new task should be
the same as it was before this PR (350px on typical viewports). The
plan's "propagate preferred widths across envs" feature changed that
in practice: after the first resize, every new task opened at the
resized width instead of the legacy default.
- Remove `getPinnedDefaults` / `setPinnedDefaults` helpers and the
sessionStorage `kandev.dockview.pinned-defaults` slot.
- Drop the write-through in `setPinnedWidth` and the seed in
`performBuildDefault`. `freshPinned` is back to `new Map()`, so a
brand-new env always renders at the ratio-based default (same as
before this PR, since `getPinnedWidth` already clamps the initial
ratio to the legacy 350/450 hard caps).
- Per-env layout persistence still works via `setEnvLayout` — when a
user resizes inside a task and switches back later, the width is
restored. Only the cross-env *default* is no longer carried over.
- Remove e2e tests + helpers that exercised the propagation contract.
- Drop the flaky `rapid resizes persist across reload` edge test —
too brittle around the debounce/beforeunload race.
- `LAYOUT_INITIAL_RATIO` introduced as the canonical name;
`LAYOUT_SIDEBAR_RATIO` / `LAYOUT_RIGHT_RATIO` kept as deprecated
aliases for backwards compatibility while sizing.ts dispatches.
* fix(layout): bump saved-layout storage keys to v2
Layouts saved by intermediate builds of this PR captured sidebar/right
widths beyond the legacy initial caps (when the larger runtime cap was
the only thing constraining serialized widths). Restoring those layouts
on the current build replays the over-wide widths, defeating the
"defaults stay the same" guarantee.
Bumping the storage key prefixes (`env-layout` → `env-layout-v2`,
`env-maximize` → `env-maximize-v2`, `dockview-layout-v1` →
`dockview-layout-v2`) invalidates legacy saves once on first open;
subsequent opens persist + restore under v2 normally. Existing users
lose stored column widths once and re-customize from defaults — a
one-time cost in exchange for the upgrade being clean.
* fix(layout): apply target env's widths after fast-path switch
Fast-path env switch (`tryFastEnvSwitch`) skips `fromJSON` for speed,
which means column widths carry over from the *outgoing* env. If a
user resized the sidebar to 480px in task A and then opens task B,
B inherits A's 480px sidebar — even with empty sessionStorage on
different dev-server ports, because the widths live in dockview's
in-memory grid state, not in storage.
Explicitly resize sidebar/right after the panel swap:
- saved layout exists for target env → use its serialized sizes
- no saved layout (brand-new env) → compute defaults via
`getPinnedWidth` (legacy initial caps: 350 sidebar / 450 right)
* fix(layout): pass measured dims to fromJSON so widths don't grow on rescale
Even on a fresh dev instance with no prior storage, brand-new tasks
were opening with oversized sidebar/right widths. Root cause: applyLayout
fed `api.width` into toSerializedDockview, but `api.width` lags the live
container during the dockview mount sequence. fromJSON wrote a layout
sized for the stale width; the subsequent `api.layout(safeWidth, ...)`
in performBuildDefault's rAF rescaled the grid proportionally — and
without the old hard cap (350/450) clamping it, the sidebar grew past
its legacy default.
Pass the measured container dimensions through applyLayout into
toSerializedDockview so fromJSON's grid.width already matches the live
container. api.layout in the rAF is then a no-op, no rescale, and
sidebar/right open at exactly their legacy defaults.
Also applies to applyBuiltInPreset and applyCustomLayout for the same
flow.
* fix(layout): lock pinned column max width to current size, unlock on drag
The real reason brand-new tasks opened with oversized sidebar/right:
dockview's splitview rebalances pinned columns proportionally on every
`api.layout` call. After we relaxed setConstraints from the legacy
350/450 hard caps to viewport-proportional runtime caps (576/1344),
nothing clamped the rebalance — sidebar grew from 350 → ~450, right
from 450 → ~530 on the first ResizeObserver tick.
New invariant: pinned-column `maximumWidth` is locked to the column's
*current* width whenever the user is not actively dragging a sash. The
rebalance has zero room to grow.
- `applyLayout` / `applyLayoutFixups` set the lock as soon as fromJSON
finishes — sidebar/right open at exactly their just-computed widths.
- `setupContainerResizeSync` re-applies the lock on every viewport
ResizeObserver tick before and after `api.layout`.
- `setupSashDragCapToggle` listens for mousedown/mouseup on `.dv-sash`:
on mousedown, widen the cap to the runtime viewport-proportional cap
so the user can drag past the legacy default; on mouseup, snap the
cap back to the new current width.
Also addresses Claude's latest review:
- caps.test.ts: rename "hits the 350px floor on roomy viewports" → "…
when vw*0.3 falls below the minimum" (test name now matches assertion).
- dockview-resize.ts: drop the stale "pinned-defaults mirror" comment;
guard the sidebar splitview index against the hidden-sidebar footgun.
- pane-resize-right.spec.ts: rename the reload test to mention the
correct localStorage key (`dockview-layout-v2`).
- new `dockview-env-switch-pinned.test.ts` unit-tests applyPinned-
ColumnSizes with a spy splitview — verifies sidebar/right are
explicitly resized after a fast-path env switch.
* fix(layout): constrain all groups, loosen e2e resize cap, drop stale docs
Round of PR-review fixes:
- applier: `applyConstraintsToAllPanelGroups` replaces the previous
first-only helper. The default right column has separate top + bottom
groups; constraining only the first left the bottom group unbounded
and let the column grow on rebalance via that group (cubic P2 +
Claude regression).
- sizing: collapse `LAYOUT_SIDEBAR_RATIO` / `LAYOUT_RIGHT_RATIO`
dispatch — they alias the same `LAYOUT_INITIAL_RATIO`, so the
ternary was a silent no-op (Claude).
- sizing: doc — drop "widths seeded from pinned-defaults sessionStorage"
reference; that feature was removed in fb3dbe4 (Claude).
- e2e/dockview-resize: the latest fix locks pinned-column maxWidth to
current width, which clamps `sv.resizeView` in tests. Loosen the
constraint to the same runtime cap the sash-drag handler uses on
user mousedown — so cap-enforcement assertions still work end-to-end
without burning the 10-minute CI timeout retrying clamped resizes.
* fix(layout): mirror viewportBound clamp in e2e test cap helper
Claude & cubic flagged that `resizeColumnViaSplitview`'s runtime-cap
computation skipped the `viewportBound(value, vw - 300)` step that
production caps go through. On narrow viewports (vw < ~1143px) the
helper's cap would exceed what dockview actually enforces, so
cap-clamp assertions would drift from real app behaviour.
Mirror the caps.ts logic exactly in the helper.
* fix(layout): pin-by-target, not lock-by-current — defeats the ratchet bug
My previous attempt (lock max=current width except during sash drag) was
fundamentally broken: any transient container shrink would lock the cap
at the smaller size, and dockview's proportional rebalance could never
restore the column. Under heavy ResizeObserver fluctuation on reload, the
sidebar marched all the way down to LAYOUT_PINNED_MIN_PX (180px).
New invariant: pinned-column max is the *runtime cap* (loose enough for
the user to drag past the legacy default), and a separate "target width"
is tracked per pinned column. After every `onDidLayoutChange` event the
column is force-restored to its target via `sv.resizeView`.
Target is updated only by explicit actions:
- `applyLayout` sets it to the computed default after `fromJSON`.
- `applyLayoutFixups` sets it to the just-restored width on saved-layout
restore.
- `applyPinnedColumnSizes` (fast-path env switch) sets it to the saved /
default width.
- The sash-drag handler sets it to the new width on mouseup.
`pinnedTargets` live in a new module `lib/state/layout-manager/pinned-targets.ts`.
`setPinnedTarget` / `getPinnedTarget` are exported from the layout-manager
barrel. An `enforcing` guard around `enforcePinnedTargets` prevents the
restore-via-resizeView from re-triggering itself.
E2E helper additions:
- `__persistDockviewLayout__` exposed on `window` (test seam) — calls the
setupLayoutPersistence `persistNow` directly. `sv.resizeView` doesn't
emit `onDidLayoutChange` in dockview 4.x, so the helper now force-flushes
the layout after each programmatic resize. Without this, reload tests
never saw a saved layout.
- `__setPinnedTarget__` exposed too so the helper can update the target
after a programmatic resize, mirroring sash-drag mouseup.
- Dropped the brittle `data-panel-id` width assertion in the tablet
persistence test; the localStorage round-trip is the persistence
contract, and `react-resizable-panels`' internal DOM is version-dependent.
Local validation: all 15 layout e2e tests pass; full unit suite (2087)
green; format + lint + typecheck clean.
* fix(layout): pause target enforcement during sash drag + cleanup test seam
Three follow-ups from the latest review round:
- cubic P1: pause `enforcePinnedTargets` while the user is actively
dragging a sash. Without this, an in-progress layout-change event
during the drag would revert the in-flight resize before mouseup
records the new target. The new `sashDragging` flag is set on
mousedown over `.dv-sash` and cleared on mouseup.
- claude: clear `window.__persistDockviewLayout__` in the
setupLayoutPersistence disposer so a remount can't be racing with a
stale closure from the previous mount.
- claude: third unit test exercises the right column's saved-layout
resize path (sv.length = 3 covering both pinned columns), now that
the previous test bounded the loop at sv.length = 2 and only
reached the sidebar.
* test(e2e): bump dockview-layout-v1 → v2 in plan-panel-indicator spec
The storage key was bumped from v1 → v2 in fb3dbe4 (drop cross-env
width propagation). The plan-panel-indicator spec was waiting for the
old `dockview-layout-v1` localStorage key to materialise after a
debounced save — that key is never written under v2, so the
waitForFunction always timed out.
* fix(layout): reset sashDragging on disposer + only track left-button drags
Two safety fixes for the module-scope `sashDragging` flag:
- claude: clear it in the setupSashDragCapToggle disposer. If the user
starts dragging a sash and unmounts mid-drag (e.g. navigates away),
the flag stayed `true` forever and `enforcePinnedTargets` would never
fire again on the next mount.
- cubic P2: only set the flag on left-button mousedown. A right/middle
click over a sash currently flipped it true, and the mouseup handler
ignores non-left buttons — so the flag would stick and pause
enforcement until a left mousedown→up cycle.
* fix(ui): preserve session mode selector state
* fix(ui): format fallback session mode labels
* feat(web): drag-reorder subtasks within a parent task
Adds per-parent subtask ordering in the sidebar. Drag a subtask under its
parent to reorder; the chosen order is independent of the global sort and
persists per browser via localStorage.
- New `sidebarTaskPrefs.subtaskOrderByParentId` slice field and matching
`setSubtaskOrder(parentId, ids)` action, persisted to localStorage under
`kandev.sidebar.subtaskOrderByParentId`.
- `applyView` applies the per-parent override after `separateSubtasks`, so
it never touches root order or the active sort spec.
- TaskSwitcher exposes `onReorderSubtasks(parentId, ids)`; each parent's
subtask list lives in its own nested DndContext / SortableContext using
the existing dnd-kit setup.
- Task-deletion cleanup strips the deleted id as both parent key and
subtask entry from `subtaskOrderByParentId` in store + localStorage.
* refactor(web): address PR review feedback on subtask reordering
- Extract `pruneSubtaskOrder(map, taskId)` helper in `local-storage.ts`;
use it in both `cleanupTaskStorage` and `removeTaskFromSidebarPrefs`
to keep the two cleanup paths in lockstep.
- Move `SortableSubtaskRow`, `SortableSubtaskList`, and `useSubtaskDnd`
into a new sibling file `task-switcher-subtask-dnd.tsx` so the
switcher stays under the 600-line soft limit.
- `SortableSubtaskList` now short-circuits to a plain row map when
`onReorderSubtasks` is undefined — read-only callers no longer get a
misleading grab cursor or unused DnD scaffolding.
* fix(web): add stable keys to non-DnD subtask render path
When `onReorderSubtasks` is undefined, the early-return branch in
`SortableSubtaskList` mapped subtasks without keys, falling back to
React's index-based reconciliation. Wrap each child in a keyed
`Fragment` so reorders or size changes reconcile correctly.
* refactor(web): single-source DRAG_ACTIVATION_DISTANCE
Export the constant from `task-switcher-subtask-dnd.tsx` and import it
in `task-switcher.tsx` instead of declaring the same value twice.
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
* feat(web): allow renaming quick-chat tabs locally
Double-click a quick-chat tab to rename it. The name is stored in
localStorage and overlays the SSR-derived task title on hydration, so
renames survive reload without touching the backend.
* fix(web): address quick-chat rename review feedback
- Drop unused sessionId prop from QuickChatTabItem
- Enter handler now blurs the input instead of calling commit(),
preventing a duplicate commit when onBlur fires on unmount
- Add hydrator tests covering the localStorage name overlay
* fix(web): make Escape actually cancel quick-chat rename
Escape was setting isEditing(false), which unmounted the focused input,
causing onBlur (commit) to fire synchronously with the typed draft still
in the closure — committing the rename instead of discarding it.
Cancel now flips a cancelledRef and blurs the input; commit short-circuits
when the ref is set, so onBlur is the single edit-exit path for both
Enter and Escape. Adds a render test covering both flows.
* fix(web): guard quick-chat rename against IME and orphan storage writes
- Skip Enter/Escape handling while IME composition is active so candidate
confirmation does not commit/cancel the rename
- renameQuickChatSession only writes to localStorage when the session was
actually found in state, preventing orphan entries for stale session IDs
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
* feat(backend): key user shells by TaskEnvironmentID
Sessions in the same task share a TaskEnvironment. Re-key the user shell
runner so terminals are scoped per env, not per session — switching between
sessions of the same task now sees the same shell list.
- Add TaskEnvironmentID to AgentExecution / LaunchRequest / ExecutorCreateRequest
/ executor.LaunchAgentRequest, propagated through ToAgentExecution and the
orchestrator launch path.
- Add Manager.ResolveScopeKey(sessionID) — returns env when execution carries
it, falls back to sessionID for legacy paths.
- Rename runner param sessionID -> scopeID in interactive_shells.go; map keys
are now scopeID:terminalID.
- Shell handlers + WS terminal handler resolve sid -> scopeID via
ResolveScopeKey before calling the runner.
* feat(web): key dockview layout + portals to TaskEnvironmentID
Switching between sessions of the same task no longer rebuilds the dockview
layout — terminals and panels persist because layouts are env-scoped, not
session-scoped. The bottom terminal also reuses a stable sessionId across
same-env switches so it doesn't reconnect to a different shell.
- Rename layout persistence: setSessionLayout/getSessionLayout/*SessionMaximizeState
-> setEnvLayout/getEnvLayout/*EnvMaximizeState (kandev.dockview.env-layout. prefix).
- Rename dockview-session-switch.ts -> dockview-env-switch.ts; performSessionSwitch
-> performEnvSwitch with oldEnvId/newEnvId/activeSessionId.
- dockview-store.ts: currentLayoutSessionId -> currentLayoutEnvId; switchSessionLayout
-> switchEnvLayout(oldEnvId, newEnvId, activeSessionId). Same-env switches no-op.
Drop _skipLayoutSwitchForSession + consumeSkipFlag — env-keyed action handles it
structurally.
- panel-portal-manager: PortalEntry.envId; releaseBySession -> releaseByEnv.
ENV_SCOPED_COMPONENTS in dockview-desktop-layout (renamed from SESSION_SCOPED).
- useEnvSwitchCleanup replaces useSessionSwitchCleanup; tracks env changes only.
- All performLayoutSwitch callers (sidebar, dropdown, header, dialog) resolve
sid -> envId via environmentIdBySessionId before switching.
- bottom-terminal-panel: drop sessionId prop, use useEnvironmentSessionId().
- cleanupTaskStorage now takes envIds[] alongside sessionIds[] for env-scoped
layout/maximize cleanup.
* test: add coverage for env-keyed terminals + layout
Playwright e2e (terminal-env-keyed.spec.ts) — switching between sessions of
the same task keeps terminal scrollback intact and asserts both sessions
share one task_environment_id. Update the comment in
sessionless-task-switch.spec.ts to reference performEnvSwitch.
* fix: address env layout PR feedback
* test: fix flaky handshake auth check
* fix: address env scope review findings
* feat(web): add sidebar view types and apply-view engine
Introduces the typed representation of a sidebar view (filter clauses,
sort spec, group key) and the pure engine that applies a view to a list
of tasks to produce a filtered, sorted, grouped result.
* feat(web): add built-in sidebar view presets
Provides the four shipped presets (All tasks, No PR reviews, Active,
Archived). The 'No PR reviews' preset filters out PR-watcher tasks that
would otherwise crowd the sidebar.
* feat(web): persist sidebar views in ui store
Adds sidebar view state to the ui slice with draft semantics for saved
views, localStorage persistence of active view + user-created views, and
root store wiring.
* feat(web): add filter dimension registry and clause editor
Registry of filterable task dimensions (status, workspace, workflow, task
type, etc.) plus the primitive UI pieces — clause editor, sort picker,
group picker, and a hook that supplies operator and value options for a
selected dimension.
* feat(web): add sidebar filter bar, popover, and view manager
Composes the filter primitives into the user-facing surfaces: the top
filter bar, the popover that hosts clause editing and sort/group
selection, the chips that switch between views, and the manager for
saving, renaming, and deleting user views.
* refactor(web): extract NewTaskButton component
Pulls the Create Task button and its command-palette registration into a
standalone memoized component in preparation for reuse across sidebar
surfaces.
* feat(web): integrate filter/view system into task sidebar
Wires the filter bar, view chips, and apply-view engine into the task
sidebar (desktop and mobile sheet). Updates task-switcher to consume the
grouped list from the engine and adds mock data for tests and previews.
* test(web): add e2e coverage for sidebar filter/view system
Page object for the filter popover plus Playwright specs covering built-in
presets, clause editing, sort/group, saved-view draft semantics, and
persistence across reloads.
* refactor(web): trim sidebar built-in views to a single seeded default
Drop the "No PR reviews", "Active", and "Archived" built-in view presets
and the isBuiltIn protection flag. The sidebar now seeds a single "All
tasks" view on first load; users can rename, overwrite, or delete it like
any other view. Deletion is guarded against removing the last remaining
view so the user can never lock themselves out.
* fix(web): humanize executor type labels in sidebar filter and grouping
The executor-type filter dropdown and group-by label were rendering raw
backend identifiers like "worktree" and "local_docker". Route both
through the existing getExecutorLabel() helper so the sidebar shows
"Worktree" and "Local Docker" like the rest of the app.
* feat(web): replace "Has PR" filter with "Is PR review"
The old dimension matched any task with an associated PR, including
tasks a user manually linked. The new dimension isolates tasks created
by the GitHub PR-review watcher specifically, which is what the filter
is useful for — hiding a wall of auto-generated review tasks.
The flag is derived from the review_watch_id metadata key the backend
already stamps onto watcher-created tasks, threaded through the kanban
snapshot and into the sidebar TaskSwitcherItem. A small migration drops
unknown dimensions (including the old "hasPR") from stored views so the
popover does not crash when reading legacy data.
* fix(web): tighten sidebar filter popover layout
Reduce the popover width (w-96 → w-[22rem]), trim section padding
(p-3 → p-2.5) and label margins (mb-2 → mb-1.5), and drop the
"No filters applied." placeholder. Extract a shared SectionLabel to
keep the three section headers visually aligned.
* feat(web): show workflow step color dot in sidebar filter dropdown
Surface the step color on the workflow-step filter's value dropdown so
each option renders with the same coloured circle used elsewhere (task
switcher, kanban column, command panel). Adds an optional color field
on filter value options; only workflow-step options populate it today.
* feat(web): replace cramped in/not-in filter editor with a multiselect dropdown
The previous "in/not in" value input rendered selected values as tiny
10px pills inside a fixed-height row that overflowed and truncated as
the list of options grew. Replace it with a combobox-style multiselect
(Popover + Command) that shows a compact summary ("N selected" or the
single label with its color dot) and opens a searchable, scrollable
list with check indicators on click.
* fix(web): show workflow and step names (not UUIDs) in sidebar grouping
Group-by Workflow and Workflow Step rendered raw UUID strings as group
headers because `TaskSwitcherItem` did not carry the resolved names.
Pass `workflowName` and `workflowStepTitle` through `toSidebarItem` and
prefer them in the group extractors.
* fix(web): drop redundant true/false selector on boolean sidebar filters
Boolean dimensions (PR review, Archived, Has diff) previously exposed
both an operator (is / is not) and a value selector (true / false),
which gives four user inputs for two real states. Hide the value input
for boolean clauses and always store `true`; negation is expressed via
the operator. Rename "Is PR review" to "PR review" since the dimension
label no longer needs to read as a question.
* refactor(web): drop Sort by Repository and relabel State to Status
Sorting by repository duplicates what Group by Repository already
offers, and "State" leaks a backend term into the picker. Remove the
repository sort key (comparator, type, picker option) and rename the
state option to "Status". Stored views referencing the removed key are
migrated back to `state` at load time so the popover does not crash.
* fix(web): tighten sidebar filter popover section spacing
The FILTERS, SORT, and GROUP BY section labels each sat above their
controls with both padded containers (p-2.5) and a 6px bottom margin,
leaving visible dead space above each header. Reduce container padding
to p-2 and label margin to mb-1 so sections read as a compact stack.
* chore(web): apply prettier formatting drift in sidebar-filter area
* revert(web): undo store.ts prettier reformat to stay under max-lines cap
The packed single-line UIA declaration in store.ts is load-bearing for
the 600-line ESLint cap: the previous chore commit unpacked it to 9
lines and pushed the file to 609 lines, turning the max-lines warning
into a lint failure. Restore the packed layout (the /* prettier-ignore */
comment is there for exactly this reason).
* refactor(web): label boolean filter ops as Show/Hide instead of is/is not
* fix(web): pull section labels flush to sidebar filter popover borders
* refactor(web): inline rename and save-as into sidebar view header row
* refactor(web): split ViewHeaderRow into mode, display, and action subcomponents
* feat(backend): persist sidebar views in user settings
Adds a SidebarViews field to UserSettings (stored in the existing
users.settings JSON blob) plus validation in the service layer:
views are capped at 50, ids and names must be non-empty and unique.
* feat(web): hydrate and sync sidebar views to backend
Moves the source of truth for saved sidebar views from localStorage
to the user settings endpoint. Views hydrate from SSR on first load;
create/rename/overwrite/delete/duplicate/toggle-collapsed all PATCH
the backend with optimistic updates and revert-on-failure. The
active view id and draft remain per-device in localStorage. A
sync bridge surfaces PATCH errors as toasts and runs a one-time
migration to upload any pre-existing local views.
As a side effect, localStorage writes and PATCH side effects are
moved out of immer producers to avoid proxy-serialization risk.
* chore(web): apply prettier to store.ts sidebar actions block
* fix(web): run migrateView on SSR-hydrated sidebar views
Defensively drop clauses with unknown dimensions and reset stale sort
keys when views come from the backend, matching the localStorage load
path. Also notes the default-state module and sidebar views in the
store structure docs.
* docs: note default-state module and sidebar views in store structure
* fix(web): type sidebar view storage wrappers properly
Drop the as-unknown-as-SidebarView and as-never casts at call sites by
making the local-storage getters/setters accept the domain type and do
the JsonValue cast once inside the wrapper.
* refactor(web): stop PATCHing on sidebar group collapse toggles
collapsedGroups is per-device visual state; routing it through
mutateViews made every expand/collapse click fire a user-settings
PATCH. Keep it local (in-memory + localStorage cache); the next real
view mutation picks up whatever state is current.
* fix(web): skip sidebar view migration when settings hydrated from server
After SSR hydration the in-memory views are already the backend's
truth, so migrating re-POSTs the same data every page load. Gate the
one-shot migration on userSettings.loaded — only run if the server
didn't answer and the slice fell back to localStorage.