Skip to content

feat(desktop): v2 workspace discovery page#3317

Merged
saddlepaddle merged 2 commits into
mainfrom
saddlepaddle/mildly-ironclad
Apr 10, 2026
Merged

feat(desktop): v2 workspace discovery page#3317
saddlepaddle merged 2 commits into
mainfrom
saddlepaddle/mildly-ironclad

Conversation

@saddlepaddle
Copy link
Copy Markdown
Collaborator

@saddlepaddle saddlepaddle commented Apr 10, 2026

Summary

  • New /v2-workspaces page that lists every v2 workspace the current user has access to across all of their devices, grouped into "In your sidebar" and "Other workspaces" (both grouped by project).
  • Each row has a single clear CTA — "Add to sidebar" / "Remove from sidebar" — plus search, device filter chips (All / This device / Other devices / Cloud), and device badges showing local vs remote vs cloud and offline state for non-local hosts.
  • Dashboard sidebar header now has a "Workspaces" nav entry (mirrors the v1 WorkspaceSidebarHeader pattern) that lights up when the discovery page is active.

Why / Context

The v2 sidebar only shows workspaces that have been explicitly added to v2WorkspaceLocalState (a per-org localStorage record), so workspaces an agent spun up in the background, workspaces a teammate created on a shared host, or workspaces the user created on another of their own machines are invisible until the user navigates to them by URL. There was a stub page at /v2-workspaces and a LuLayers "Workspaces" button pattern already in the v1 sidebar — this PR turns the stub into a real browse surface and wires up the nav entry.

Two main user journeys this serves:

  1. "An agent spun up a workspace while I was away — where is it?" → recency-sorted with a pinned vs. other split.
  2. "I want to check out a teammate's workspace / my own workspace on another machine" → grouped by project with device badges and a device filter.

How It Works

Data is read entirely from Electric collections via a single useLiveQuery join in useAccessibleV2Workspaces:

v2Workspaces
  ⋈ v2Hosts (host lookup)
  ⋈ v2UsersHosts (access filter — currentUserId)
  ⋈ v2Projects
  leftJoin v2WorkspaceLocalState (pinned?)
  leftJoin users (creator name)
  where organizationId = activeOrganizationId

No new tRPC procedures — this matches the existing useWorkspaceHostOptions device-picker pattern. Access is gated client-side by joining v2_users_hosts; workspaces on hosts the user has no row for are hidden (no dead-end rows). hostType (local-device / remote-device / cloud) is computed client-side by comparing hostMachineId to the local machineId, same as useDashboardSidebarData.

Adding a row to the sidebar calls the existing ensureWorkspaceInSidebar(workspaceId, projectId) helper, which upserts both the workspace and its parent project into v2SidebarProjects and v2WorkspaceLocalState — so workspaces in un-pinned projects just work without any new pinning UI. Opening a workspace navigates to /v2-workspace/$workspaceId, and the existing v2-workspace/layout.tsx handles local-vs-relay URL resolution uniformly for local, remote, and cloud hosts.

File layout (co-located under v2-workspaces/ per AGENTS.md)

_dashboard/v2-workspaces/
├── page.tsx                                  (thin composition of hook + header + list)
├── hooks/useAccessibleV2Workspaces/          (the useLiveQuery join)
├── stores/v2WorkspacesFilterStore/           (zustand: searchQuery + deviceFilter)
└── components/
    ├── V2WorkspacesHeader/                   (title, search, ToggleGroup filter chips)
    └── V2WorkspacesList/                     (pinned + others sections, empty states)
        └── components/V2WorkspaceRow/        (Item row with single primary CTA)
            └── components/V2WorkspaceDeviceBadge/

All UI is built from @superset/ui shadcn primitives (Item, ItemGroup, Badge, Empty, InputGroup, ToggleGroup, Button, ScrollArea, Tooltip).

Manual QA Checklist

Discovery page content

  • Page renders with header, search, device filter chips, and correct counts
  • Workspaces on hosts the user has access to are visible
  • Pinned workspaces appear under "In your sidebar"; un-pinned under "Other workspaces"; both grouped by project
  • Device badge shows correct icon per host type (laptop / monitor / cloud) and host name
  • Local device badge never shows "offline" even if v2_hosts.isOnline is stale
  • createdAt renders relative time without crashing (string → Date normalization)

Row actions

  • Clicking anywhere on a row opens the workspace
  • Unpinned row shows "+ Add to sidebar" primary button; clicking it pins the workspace and its parent project, and the row moves to "In your sidebar"
  • Pinned row shows "− Remove from sidebar" outline button; clicking it removes the workspace from the sidebar and the row moves to "Other workspaces"
  • "Remove from sidebar" is disabled when the row matches the currently-viewed workspace

Sidebar nav entry

  • DashboardSidebarHeader shows a "Workspaces" button with LuLayers icon above "New Workspace" (expanded variant)
  • Collapsed variant shows icon-only button with tooltip
  • Active state highlights when on /v2-workspaces

Filters + search

  • Search filters on workspace name, project name, branch, host name, and creator name
  • Device filter chips restrict to This device / Other devices / Cloud with live counts
  • "No matches" empty state with "Clear filters" button appears when search/filter excludes everything
  • "No workspaces yet" empty state appears when the user has zero accessible workspaces

Not verified (noted for reviewer)

  • Cloud host row (my test org had no cloud hosts) — code path exists and reuses the same relay routing as existing workspaces, but I couldn't exercise it directly
  • Offline remote device badge + tooltip — couldn't trigger isOnline=false on a remote host in my dev setup

Testing

  • bun run lint — clean
  • bun run typecheck — clean across all packages
  • bun run test@superset/desktop 1629 pass / 0 fail (via turbo)
  • bunx sherif — no issues
  • Manual testing in dev mode, see QA checklist above

Design Decisions

  • Filter by v2_users_hosts rather than surfacing all org workspaces grayed out: matches the create-workspace device picker (useWorkspaceHostOptions), keeps the UI from showing rows the user can't actually use, and avoids introducing a "can't open this" state.
  • Group pinned workspaces by project (not just a flat list): consistency with the "Other workspaces" section — both halves of the page share the same renderProjectGroups helper.
  • Single primary CTA per row, no dropdown/overflow menu: initial design had an "Open" button + ellipsis menu next to it, which looked visually noisy. Row click already opens, so the visible button is dedicated to the sidebar pin toggle.
  • Read everything from Electric, no new tRPC procedure: all the required tables (v2_workspaces, v2_hosts, v2_users_hosts, v2_projects, users) are already synced org-wide; adding a server procedure would duplicate data that's already on the client.

Known Limitations

  • Pinned-but-inaccessible workspaces (user lost host access after pinning) are hidden by the v2_users_hosts inner join. The stale v2WorkspaceLocalState entry in the sidebar is a pre-existing issue, not introduced here.
  • Creator name falls back to "unknown" when createdByUserId is set but no matching users row is synced yet.

Follow-ups

  • Keyboard/command-palette entry for the page
  • Surface "Created by me" as a filter chip
  • Cross-org workspace discovery (current scope is active org only)

Risks / Rollout

  • Risk: Low. Read-only page against already-synced Electric shapes; the only mutations are the existing ensureWorkspaceInSidebar / removeWorkspaceFromSidebar helpers that write to localStorage.
  • Rollback: Revert the commit. The stub page existed before this PR and can be restored from git history if needed.

Summary by cubic

Adds a v2 workspace discovery page at /v2-workspaces to browse all accessible workspaces across devices with search, device filters, and an add/remove-to-sidebar flow. Adds a “Workspaces” nav button in the dashboard sidebar; improves row accessibility and makes device-filter counts reflect search, with filters reset on page load.

  • New Features

    • New browse page split into “In your sidebar” and “Other workspaces,” grouped by project.
    • Search by workspace, project, branch, device, or creator; device filter chips (All / This device / Other devices / Cloud) with counts.
    • Device badges show local/remote/cloud and offline state for non-local hosts.
    • Row is clickable to open; single CTA: “Add to sidebar” or “Remove from sidebar” (disabled when viewing that workspace).
    • Dashboard sidebar header adds a “Workspaces” button (expanded and collapsed variants) that highlights on /v2-workspaces.
    • Data reads via a single useLiveQuery join; access filtered by v2_users_hosts; host type derived from local machineId; uses existing ensureWorkspaceInSidebar and removeWorkspaceFromSidebar.
  • Bug Fixes

    • Fixed invalid nested buttons in rows; click/keyboard handlers moved to the row container (role="button", tabIndex=0) for proper accessibility.
    • Device filter chip counts now reflect the active search query (useAccessibleV2Workspaces applies search before deriving counts).
    • Search and device filter reset on page mount to avoid stale filters when returning to the page.

Written for commit 41a5411. Summary will update on new commits.

Summary by CodeRabbit

  • New Features
    • Added "Workspaces" dashboard entry with active-state highlighting.
    • New Workspaces page with header (search + device filter with counts) and grouped, searchable workspace list.
    • Workspace rows show project, branch, creator, relative creation time, device badge (online/offline) and keyboard-accessible navigation.
    • Actions to add/remove workspaces from the sidebar; empty and filtered states with "Clear filters" option.

Build a browse surface for every v2 workspace the current user can
access across all of their devices, and wire up the stub "Workspaces"
entry in the dashboard sidebar header to it.

- New page at /v2-workspaces reads v2Workspaces, v2Hosts, v2Projects,
  v2UsersHosts, and v2WorkspaceLocalState directly from Electric
  collections via a single useLiveQuery join in
  useAccessibleV2Workspaces. Access is filtered by v2_users_hosts —
  workspaces on hosts the user has no row for are hidden, matching the
  create-workspace device picker.
- Workspaces are split into "In your sidebar" (pinned) and "Other
  workspaces", each grouped by project. Rows surface project name,
  branch, device badge (local/remote/cloud with offline state for
  remote), creation time, and creator.
- Single primary CTA per row: "Add to sidebar" when unpinned,
  "Remove from sidebar" when pinned (disabled on the currently-viewed
  workspace). Whole row is clickable to open; opening a remote/cloud
  workspace reuses the existing relay routing in
  v2-workspace/layout.tsx.
- Adding pins both the workspace and its parent project via the
  existing ensureWorkspaceInSidebar helper, so workspaces in
  un-pinned projects just work.
- Search + device filter chips (All / This device / Other devices /
  Cloud) driven by a small zustand store.
- DashboardSidebarHeader now has a "Workspaces" nav button with an
  active state that lights up on /v2-workspaces, mirroring the v1
  WorkspaceSidebarHeader pattern in both collapsed and expanded
  variants.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 10, 2026

📝 Walkthrough

Walkthrough

Adds a "Workspaces" sidebar entry and implements a full V2 Workspaces feature: a data hook, filter store, header with search/filters, grouped workspace list, per-row actions, and device-status badges, plus page integration and navigation wiring to /v2-workspaces.

Changes

Cohort / File(s) Summary
Sidebar Navigation
apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx
Added "Workspaces" sidebar entry with route-match active state and navigation to /v2-workspaces; renders icon-only in collapsed mode and labeled button when expanded.
Page Integration
apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/page.tsx
Replaced WIP markup with integrated page: resets filters on mount, reads filter store, fetches workspaces via hook, and composes header and list components.
Data hook & types
.../v2-workspaces/hooks/useAccessibleV2Workspaces/useAccessibleV2Workspaces.ts, .../useAccessibleV2Workspaces/index.ts
New hook to fetch/enrich accessible v2 workspaces (joins hosts, projects, creators, sidebar state), classify hostType (local-device/remote-device/cloud), compute pinned/others and counts; exports related types.
Filter store
.../v2-workspaces/stores/v2WorkspacesFilterStore/v2WorkspacesFilterStore.ts, .../index.ts
New Zustand store for searchQuery and deviceFilter (all,this-device,other-devices,cloud) with setters and reset(); added index re-export.
Header component
apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesHeader/V2WorkspacesHeader.tsx, .../index.ts
New header component with search input and device-filter ToggleGroup; bound to filter store and displays per-filter counts.
List & row components
.../V2WorkspacesList/V2WorkspacesList.tsx, .../V2WorkspaceRow/V2WorkspaceRow.tsx, .../V2WorkspaceRow/index.ts
New list component grouping workspaces by project, handling empty and "no matches" states, applying device filter; row component handles navigation, keyboard activation, and add/remove-from-sidebar actions.
Device badge
.../V2WorkspaceDeviceBadge/V2WorkspaceDeviceBadge.tsx, .../index.ts
Badge component showing host icon/name, computes offline treatment (tooltip + offline dot) for non-local hosts; exported via index.

Sequence Diagram

sequenceDiagram
    participant User as User
    participant Sidebar as DashboardSidebarHeader
    participant Nav as Router
    participant Page as V2WorkspacesPage
    participant Hook as useAccessibleV2Workspaces
    participant Store as v2WorkspacesFilterStore
    participant Header as V2WorkspacesHeader
    participant List as V2WorkspacesList

    User->>Sidebar: Click "Workspaces"
    Sidebar->>Nav: navigate("/v2-workspaces")
    Nav->>Page: render V2WorkspacesPage

    Page->>Store: reset() on mount
    Page->>Store: read searchQuery
    Page->>Hook: useAccessibleV2Workspaces({ searchQuery })
    activate Hook
    Hook->>Hook: query & join hosts/projects/creators/sidebar
    Hook->>Hook: classify hostType, compute counts, split pinned/others
    deactivate Hook
    Hook-->>Page: return { all, pinned, others, counts }

    Page->>Header: pass counts
    Page->>List: pass pinned, others, hasAnyAccessible

    User->>Header: update search or device filter
    Header->>Store: setSearchQuery / setDeviceFilter

    List->>Store: read searchQuery & deviceFilter
    List->>List: apply filters, group by project, render rows
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

Hop, hop — a new list to explore,
Workspaces gathered, badges galore.
Search and filters tidy the scene,
From cloud to local, both seen.
The rabbit clicks in joy — adventures more! 🐰✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely describes the primary change: a new v2 workspace discovery page feature for the desktop application.
Description check ✅ Passed The description is comprehensive and well-structured, covering summary, context, implementation details, file layout, QA checklist, design decisions, limitations, and testing results.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch saddlepaddle/mildly-ironclad

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Apr 10, 2026

Greptile Summary

This PR implements the v2 workspace discovery page (/v2-workspaces), turning a stub route into a full browsable surface that lists all workspaces accessible to the current user across their devices, grouped into "In your sidebar" and "Other workspaces" sections. A "Workspaces" nav entry is also wired into the DashboardSidebarHeader in both expanded and collapsed variants.

Data flows entirely from Electric collections via a single useLiveQuery join in useAccessibleV2Workspaces, with no new tRPC procedures. Mutations reuse the existing ensureWorkspaceInSidebar/removeWorkspaceFromSidebar helpers. The file layout follows the co-location pattern defined in AGENTS.md.

Key changes:

  • New useAccessibleV2Workspaces hook joining v2Workspaces, v2Hosts, v2UsersHosts, v2Projects, v2WorkspaceLocalState, and users via a single live query
  • New v2WorkspacesFilterStore (Zustand) holding searchQuery and deviceFilter state
  • New V2WorkspacesHeader with search input and device filter ToggleGroup
  • New V2WorkspacesList with pinned/others sections, per-project grouping, search+device filtering, and two empty states
  • New V2WorkspaceRow with sidebar pin/unpin CTA and V2WorkspaceDeviceBadge
  • DashboardSidebarHeader updated with a "Workspaces" button (collapsed icon + expanded label), wired to /v2-workspaces with active-state highlighting

Confidence Score: 4/5

Safe to merge — read-only page with low-risk mutations; no new server procedures or schema changes.

The implementation is clean, well-structured, and follows existing codebase patterns. The only notable issue is that the device-filter chip counts are computed before the search filter is applied, creating a visual mismatch when search is active. This is a UX inconsistency rather than a data-loss or reliability bug. All other logic (deduplication, host-type resolution, pin/unpin actions, active-route guard) is correct.

useAccessibleV2Workspaces.ts — counts computed before search filter; v2WorkspacesFilterStore.ts — state persists across navigation without explicit reset.

Important Files Changed

Filename Overview
apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces/useAccessibleV2Workspaces.ts Core data hook joining six Electric collections; deduplication + host-type resolution is correct. Counts are computed from all workspaces without respect to the active search query, causing a mismatch between chip counts and visible list items when search is active.
apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx List component with correct filtering, project grouping, and two empty-state variants; minor dead-code: showProjectName prop on V2WorkspaceRow is always passed as false.
apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/V2WorkspaceRow.tsx Row with correct stopPropagation on sidebar CTA clicks, proper disable guard on Remove from sidebar for the current workspace, and clear creator label logic.
apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesHeader/V2WorkspacesHeader.tsx Header with search + device-filter ToggleGroup; correctly guards against ToggleGroup deselect. Displays counts from the hook which don't account for active search terms.
apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/stores/v2WorkspacesFilterStore/v2WorkspacesFilterStore.ts Simple Zustand store; filter state persists across navigation (no reset on unmount), which may leave stale search/filter when returning to the page.
apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx Correctly adds Workspaces nav entry in both collapsed (icon+tooltip) and expanded (label) variants with active-state highlighting via matchRoute.
apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/components/V2WorkspaceDeviceBadge/V2WorkspaceDeviceBadge.tsx Clean badge component; correctly suppresses stale offline state for local-device, wraps offline badges in a tooltip.
apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/page.tsx Thin composition layer; correctly derives hasAnyAccessible from pinned+others lengths.

Sequence Diagram

sequenceDiagram
    participant User
    participant V2WorkspacesPage
    participant useAccessibleV2Workspaces
    participant ElectricCollections
    participant V2WorkspacesHeader
    participant V2WorkspacesList
    participant V2WorkspaceRow
    participant useDashboardSidebarState

    User->>V2WorkspacesPage: navigates to /v2-workspaces
    V2WorkspacesPage->>useAccessibleV2Workspaces: call hook
    useAccessibleV2Workspaces->>ElectricCollections: useLiveQuery join (v2Workspaces ⋈ v2Hosts ⋈ v2UsersHosts ⋈ v2Projects ⋉ v2WorkspaceLocalState ⋉ users)
    ElectricCollections-->>useAccessibleV2Workspaces: raw rows
    useAccessibleV2Workspaces-->>V2WorkspacesPage: { pinned, others, counts }

    V2WorkspacesPage->>V2WorkspacesHeader: counts prop
    V2WorkspacesPage->>V2WorkspacesList: pinned, others, hasAnyAccessible

    V2WorkspacesList->>V2WorkspacesList: apply searchQuery + deviceFilter (from Zustand store)
    V2WorkspacesList->>V2WorkspacesList: groupByProject(filtered)

    User->>V2WorkspaceRow: clicks Add to sidebar
    V2WorkspaceRow->>useDashboardSidebarState: ensureWorkspaceInSidebar(workspaceId, projectId)
    useDashboardSidebarState->>ElectricCollections: upsert v2WorkspaceLocalState + v2SidebarProjects (localStorage)
    ElectricCollections-->>useAccessibleV2Workspaces: live update triggers re-query
    useAccessibleV2Workspaces-->>V2WorkspacesList: row moves pinned to others
Loading

Comments Outside Diff (1)

  1. apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx, line 400-407 (link)

    P2 showProjectName prop is always false

    V2WorkspaceRow accepts a showProjectName prop and has conditional rendering logic for it, but every call site in V2WorkspacesList passes showProjectName={false}, making that code path dead in the current rendering flow (project names are shown as section group headers instead).

    If this prop is future-proofing for a flat-list view, a short comment to that effect would help. If it's not needed right now, removing the prop and its conditional block from V2WorkspaceRow simplifies the API and avoids confusion for future contributors.

Reviews (1): Last reviewed commit: "feat(desktop): v2 workspace discovery pa..." | Re-trigger Greptile

Comment on lines +154 to +169
const counts = useMemo<V2WorkspaceDeviceCounts>(() => {
let thisDevice = 0;
let otherDevices = 0;
let cloud = 0;
for (const workspace of enriched) {
if (workspace.hostType === "local-device") thisDevice += 1;
else if (workspace.hostType === "remote-device") otherDevices += 1;
else cloud += 1;
}
return {
all: enriched.length,
thisDevice,
otherDevices,
cloud,
};
}, [enriched]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Device counts don't reflect the active search query

counts is derived from all enriched workspaces before any search filter is applied. This is then rendered in V2WorkspacesHeader as the number shown on each device-filter chip. When the user has an active search query, the chip labels (e.g., "This device: 3") will stay at their pre-search totals while the visible list shows fewer rows — potentially zero. A user clicking "This device" while searching "foo" will see the chip say "3" but only land on the actual matches.

Consider computing the counts from the search-filtered set, or accepting a searchQuery parameter here. One approach — move count computation into V2WorkspacesList (which already has filteredPinnedGroups + filteredOtherGroups) and pass the counts up to the header:

// In V2WorkspacesList, derive counts from the post-search filtered workspaces
const filteredAll = [...filteredPinned, ...filteredOthers];
const counts = {
  all: filteredAll.length,
  thisDevice: filteredAll.filter(w => w.hostType === "local-device").length,
  otherDevices: filteredAll.filter(w => w.hostType === "remote-device").length,
  cloud: filteredAll.filter(w => w.hostType === "cloud").length,
};

Alternatively, the hook could accept or read the searchQuery from the store directly and apply the text-match filter before computing counts.

Comment on lines +17 to +25
export const useV2WorkspacesFilterStore = create<V2WorkspacesFilterState>()(
(set) => ({
searchQuery: "",
deviceFilter: "all",
setSearchQuery: (searchQuery) => set({ searchQuery }),
setDeviceFilter: (deviceFilter) => set({ deviceFilter }),
reset: () => set({ searchQuery: "", deviceFilter: "all" }),
}),
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Filter state persists across navigation sessions

Because useV2WorkspacesFilterStore is a module-level Zustand singleton (no persist middleware, but also no cleanup), the searchQuery and deviceFilter values survive navigation away from and back to /v2-workspaces. A user who searched for "old-project" and left the page will return to a pre-filtered view with no indication that a filter is active.

If the intent is to always start fresh, reset state when the route mounts — e.g., call reset() in a useEffect in V2WorkspacesPage:

// In page.tsx
const resetFilters = useV2WorkspacesFilterStore((state) => state.reset);
useEffect(() => {
  resetFilters();
}, []);

If persisting filters across navigation is deliberate, it may be worth adding a "Filters active" indicator so users aren't confused by a pre-filtered view.

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 14 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces/useAccessibleV2Workspaces.ts">

<violation number="1" location="apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces/useAccessibleV2Workspaces.ts:154">
P2: Device filter chip counts are computed from all workspaces, ignoring the active search query. When a user searches for e.g. "foo", the chips will still show "This device: 5" while only 1 (or 0) rows are actually visible, making the counts misleading. Consider computing counts from the post-search-filtered set, or accepting `searchQuery` in this hook so the counts reflect what's actually displayed.</violation>
</file>

<file name="apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/V2WorkspaceRow.tsx">

<violation number="1" location="apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/V2WorkspaceRow.tsx:80">
P1: Avoid nesting action buttons inside a row-level `<button>`; this produces invalid interactive markup and can break keyboard/click behavior.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx (1)

186-211: Consider extracting renderProjectGroups to a named component.

The inline renderProjectGroups function creates a new function reference on each render. While this doesn't cause correctness issues here, extracting it to a memoized component or moving it outside the component would be slightly cleaner. This is a minor consideration and acceptable as-is given the scope.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx`
around lines 186 - 211, Extract the inline renderProjectGroups function into a
separate, named React component (e.g., ProjectGroupsList) declared outside the
parent component and accept props: groups: ProjectGroup[] and
currentWorkspaceId: string; move the JSX that maps groups/workspaces and
references ItemGroup and V2WorkspaceRow into that component and export/use it
from the parent, and wrap it with React.memo to avoid creating a new function
reference on each render.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/V2WorkspaceRow.tsx`:
- Around line 74-137: The row currently wraps the whole item in a native
<button> (via Item asChild with a button) while also rendering interactive
Button components inside (Button in ItemActions), creating invalid nested
buttons; fix by replacing the outer native <button> with a non-interactive
wrapper (e.g., a div used as the row root) and attach the click behavior to it:
move handleOpen to the wrapper's onClick and add keyboard support (onKeyDown
handling Enter/Space and tabIndex=0 or use role="button") so inner Button
components (handleAddToSidebar, handleRemoveFromSidebar) remain real buttons;
update the JSX around Item / ItemContent / ItemActions / ItemMedia to use that
wrapper and ensure accessibility handlers call handleOpen.

---

Nitpick comments:
In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx`:
- Around line 186-211: Extract the inline renderProjectGroups function into a
separate, named React component (e.g., ProjectGroupsList) declared outside the
parent component and accept props: groups: ProjectGroup[] and
currentWorkspaceId: string; move the JSX that maps groups/workspaces and
references ItemGroup and V2WorkspaceRow into that component and export/use it
from the parent, and wrap it with React.memo to avoid creating a new function
reference on each render.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b190c71c-9f0d-40ed-bb29-83c4b373770c

📥 Commits

Reviewing files that changed from the base of the PR and between e0b99ef and c253643.

📒 Files selected for processing (14)
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesHeader/V2WorkspacesHeader.tsx
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesHeader/index.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/V2WorkspaceRow.tsx
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/components/V2WorkspaceDeviceBadge/V2WorkspaceDeviceBadge.tsx
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/components/V2WorkspaceDeviceBadge/index.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/index.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/index.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces/index.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces/useAccessibleV2Workspaces.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/page.tsx
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/stores/v2WorkspacesFilterStore/index.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/stores/v2WorkspacesFilterStore/v2WorkspacesFilterStore.ts

- Stop nesting Button elements inside the row-level <button>.
  V2WorkspaceRow now applies the click + keyboard handlers directly
  to the Item container via role="button" + tabIndex=0 instead of
  wrapping an inner <button>, so the inner Add/Remove CTAs are no
  longer nested interactive elements (invalid HTML, broke a11y).
- Device filter chip counts now reflect the active search query.
  useAccessibleV2Workspaces accepts an optional searchQuery param
  and applies it before deriving pinned/others/counts, so a user
  searching "foo" sees "This device: 1" instead of the pre-search
  "This device: 5". V2WorkspacesList is simplified to only narrow
  by device filter since search is already applied upstream.
- Reset search + device filter on page mount. The zustand store
  is a module-level singleton with no persistence; without an
  explicit reset, navigating away and back showed a pre-filtered
  view with no visible indication that a filter was active.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx (1)

67-75: Precompute group recency before sorting to reduce repeated work.

Math.max(...map(...)) runs repeatedly during sort comparisons. Consider computing a latestCreatedAt once per group, then sorting by that field.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx`
around lines 67 - 75, The sort repeatedly computes each group's newest timestamp
via Math.max(...group.workspaces.map(...)), which is wasteful; before sorting
the values from groupsById, iterate once over Array.from(groupsById.values())
and attach a computed latestCreatedAt (e.g., number from Math.max of
workspace.createdAt.getTime() or 0) to each group object or build a temporary
array of {group, latestCreatedAt}, then sort by that latestCreatedAt
(descending) and return the groups; update references to workspaces and
createdAt accordingly so the comparison no longer recomputes timestamps during
sort.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/page.tsx`:
- Around line 25-27: The current hasAnyAccessible calculation uses filtered
arrays (pinned/others) from useAccessibleV2Workspaces which flattens to false on
search; update the hook useAccessibleV2Workspaces to also expose totalAccessible
(set to the pre-search count, e.g., enriched.length) and then change the page's
hasAnyAccessible to derive from totalAccessible > 0 instead of
pinned.length/others.length so empty-state selection reflects actual accessible
workspaces regardless of search filtering.

---

Nitpick comments:
In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx`:
- Around line 67-75: The sort repeatedly computes each group's newest timestamp
via Math.max(...group.workspaces.map(...)), which is wasteful; before sorting
the values from groupsById, iterate once over Array.from(groupsById.values())
and attach a computed latestCreatedAt (e.g., number from Math.max of
workspace.createdAt.getTime() or 0) to each group object or build a temporary
array of {group, latestCreatedAt}, then sort by that latestCreatedAt
(descending) and return the groups; update references to workspaces and
createdAt accordingly so the comparison no longer recomputes timestamps during
sort.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c87f486c-ba4e-4753-acd3-b9861cff61ef

📥 Commits

Reviewing files that changed from the base of the PR and between c253643 and 41a5411.

📒 Files selected for processing (4)
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/V2WorkspaceRow.tsx
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces/useAccessibleV2Workspaces.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/page.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/V2WorkspaceRow.tsx

Comment on lines +25 to +27
const { pinned, others, counts } = useAccessibleV2Workspaces({ searchQuery });
const hasAnyAccessible = pinned.length > 0 || others.length > 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

hasAnyAccessible is derived from search-filtered lists, which breaks empty-state selection.

At Line 26, searching to zero matches makes hasAnyAccessible false, so users can see “No workspaces yet” even when they do have accessible workspaces.

Suggested fix
- const { pinned, others, counts } = useAccessibleV2Workspaces({ searchQuery });
- const hasAnyAccessible = pinned.length > 0 || others.length > 0;
+ const { pinned, others, counts, totalAccessible } =
+   useAccessibleV2Workspaces({ searchQuery });
+ const hasAnyAccessible = totalAccessible > 0;

And in the hook contract, expose totalAccessible from the pre-search dataset (enriched.length).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/page.tsx`
around lines 25 - 27, The current hasAnyAccessible calculation uses filtered
arrays (pinned/others) from useAccessibleV2Workspaces which flattens to false on
search; update the hook useAccessibleV2Workspaces to also expose totalAccessible
(set to the pre-search count, e.g., enriched.length) and then change the page's
hasAnyAccessible to derive from totalAccessible > 0 instead of
pinned.length/others.length so empty-state selection reflects actual accessible
workspaces regardless of search filtering.

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 4 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/page.tsx">

<violation number="1" location="apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/page.tsx:25">
P2: Applying search in `useAccessibleV2Workspaces` makes `hasAnyAccessible` search-dependent, so non-matching queries can incorrectly render the "No workspaces yet" state instead of "No workspaces match your filters."</violation>
</file>

<file name="apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/V2WorkspaceRow.tsx">

<violation number="1" location="apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/V2WorkspaceRow.tsx:75">
P2: Row-level key handling also fires when keyboard-activating the inner action buttons, which can navigate away unexpectedly on Enter/Space.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

</p>
</div>
</div>
const { pinned, others, counts } = useAccessibleV2Workspaces({ searchQuery });
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Applying search in useAccessibleV2Workspaces makes hasAnyAccessible search-dependent, so non-matching queries can incorrectly render the "No workspaces yet" state instead of "No workspaces match your filters."

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/page.tsx, line 25:

<comment>Applying search in `useAccessibleV2Workspaces` makes `hasAnyAccessible` search-dependent, so non-matching queries can incorrectly render the "No workspaces yet" state instead of "No workspaces match your filters."</comment>

<file context>
@@ -10,7 +12,17 @@ export const Route = createFileRoute(
+		resetFilters();
+	}, [resetFilters]);
+
+	const { pinned, others, counts } = useAccessibleV2Workspaces({ searchQuery });
 	const hasAnyAccessible = pinned.length > 0 || others.length > 0;
 
</file context>
Fix with Cubic


const handleRowKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Enter" || event.key === " ") {
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Row-level key handling also fires when keyboard-activating the inner action buttons, which can navigate away unexpectedly on Enter/Space.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/V2WorkspaceRow.tsx, line 75:

<comment>Row-level key handling also fires when keyboard-activating the inner action buttons, which can navigate away unexpectedly on Enter/Space.</comment>

<file context>
@@ -70,71 +70,82 @@ export function V2WorkspaceRow({
 
+	const handleRowKeyDown = useCallback(
+		(event: React.KeyboardEvent<HTMLDivElement>) => {
+			if (event.key === "Enter" || event.key === " ") {
+				event.preventDefault();
+				handleOpen();
</file context>
Suggested change
if (event.key === "Enter" || event.key === " ") {
if ((event.key === "Enter" || event.key === " ") && event.target === event.currentTarget) {
Fix with Cubic

@saddlepaddle saddlepaddle merged commit cd25bee into main Apr 10, 2026
7 checks passed
@github-actions
Copy link
Copy Markdown
Contributor

🧹 Preview Cleanup Complete

The following preview resources have been cleaned up:

  • ⚠️ Neon database branch
  • ⚠️ Electric Fly.io app

Thank you for your contribution! 🎉

MocA-Love pushed a commit to MocA-Love/superset that referenced this pull request Apr 10, 2026
* feat(desktop): v2 workspace discovery page

Build a browse surface for every v2 workspace the current user can
access across all of their devices, and wire up the stub "Workspaces"
entry in the dashboard sidebar header to it.

- New page at /v2-workspaces reads v2Workspaces, v2Hosts, v2Projects,
  v2UsersHosts, and v2WorkspaceLocalState directly from Electric
  collections via a single useLiveQuery join in
  useAccessibleV2Workspaces. Access is filtered by v2_users_hosts —
  workspaces on hosts the user has no row for are hidden, matching the
  create-workspace device picker.
- Workspaces are split into "In your sidebar" (pinned) and "Other
  workspaces", each grouped by project. Rows surface project name,
  branch, device badge (local/remote/cloud with offline state for
  remote), creation time, and creator.
- Single primary CTA per row: "Add to sidebar" when unpinned,
  "Remove from sidebar" when pinned (disabled on the currently-viewed
  workspace). Whole row is clickable to open; opening a remote/cloud
  workspace reuses the existing relay routing in
  v2-workspace/layout.tsx.
- Adding pins both the workspace and its parent project via the
  existing ensureWorkspaceInSidebar helper, so workspaces in
  un-pinned projects just work.
- Search + device filter chips (All / This device / Other devices /
  Cloud) driven by a small zustand store.
- DashboardSidebarHeader now has a "Workspaces" nav button with an
  active state that lights up on /v2-workspaces, mirroring the v1
  WorkspaceSidebarHeader pattern in both collapsed and expanded
  variants.

* fix(desktop): address review feedback on v2 workspace discovery

- Stop nesting Button elements inside the row-level <button>.
  V2WorkspaceRow now applies the click + keyboard handlers directly
  to the Item container via role="button" + tabIndex=0 instead of
  wrapping an inner <button>, so the inner Add/Remove CTAs are no
  longer nested interactive elements (invalid HTML, broke a11y).
- Device filter chip counts now reflect the active search query.
  useAccessibleV2Workspaces accepts an optional searchQuery param
  and applies it before deriving pinned/others/counts, so a user
  searching "foo" sees "This device: 1" instead of the pre-search
  "This device: 5". V2WorkspacesList is simplified to only narrow
  by device filter since search is already applied upstream.
- Reset search + device filter on page mount. The zustand store
  is a module-level singleton with no persistence; without an
  explicit reset, navigating away and back showed a pre-filtered
  view with no visible indication that a filter was active.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant