feat(desktop): v2 workspace discovery page#3317
Conversation
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.
📝 WalkthroughWalkthroughAdds 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 Changes
Sequence DiagramsequenceDiagram
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
Greptile SummaryThis PR implements the v2 workspace discovery page ( Data flows entirely from Electric collections via a single Key changes:
Confidence Score: 4/5Safe 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
Sequence DiagramsequenceDiagram
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
|
| 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]); |
There was a problem hiding this comment.
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.
| export const useV2WorkspacesFilterStore = create<V2WorkspacesFilterState>()( | ||
| (set) => ({ | ||
| searchQuery: "", | ||
| deviceFilter: "all", | ||
| setSearchQuery: (searchQuery) => set({ searchQuery }), | ||
| setDeviceFilter: (deviceFilter) => set({ deviceFilter }), | ||
| reset: () => set({ searchQuery: "", deviceFilter: "all" }), | ||
| }), | ||
| ); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx (1)
186-211: Consider extractingrenderProjectGroupsto a named component.The inline
renderProjectGroupsfunction 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
📒 Files selected for processing (14)
apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsxapps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesHeader/V2WorkspacesHeader.tsxapps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesHeader/index.tsapps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsxapps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/V2WorkspaceRow.tsxapps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/components/V2WorkspaceDeviceBadge/V2WorkspaceDeviceBadge.tsxapps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/components/V2WorkspaceDeviceBadge/index.tsapps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/index.tsapps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/index.tsapps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces/index.tsapps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces/useAccessibleV2Workspaces.tsapps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/page.tsxapps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/stores/v2WorkspacesFilterStore/index.tsapps/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.
There was a problem hiding this comment.
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 alatestCreatedAtonce 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
📒 Files selected for processing (4)
apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsxapps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/V2WorkspaceRow.tsxapps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces/useAccessibleV2Workspaces.tsapps/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
| const { pinned, others, counts } = useAccessibleV2Workspaces({ searchQuery }); | ||
| const hasAnyAccessible = pinned.length > 0 || others.length > 0; | ||
|
|
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 }); |
There was a problem hiding this comment.
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>
|
|
||
| const handleRowKeyDown = useCallback( | ||
| (event: React.KeyboardEvent<HTMLDivElement>) => { | ||
| if (event.key === "Enter" || event.key === " ") { |
There was a problem hiding this comment.
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>
| if (event.key === "Enter" || event.key === " ") { | |
| if ((event.key === "Enter" || event.key === " ") && event.target === event.currentTarget) { |
🧹 Preview Cleanup CompleteThe following preview resources have been cleaned up:
Thank you for your contribution! 🎉 |
* 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.
Summary
/v2-workspacespage 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).WorkspaceSidebarHeaderpattern) 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-workspacesand aLuLayers"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:
How It Works
Data is read entirely from Electric collections via a single
useLiveQueryjoin inuseAccessibleV2Workspaces:No new tRPC procedures — this matches the existing
useWorkspaceHostOptionsdevice-picker pattern. Access is gated client-side by joiningv2_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 comparinghostMachineIdto the localmachineId, same asuseDashboardSidebarData.Adding a row to the sidebar calls the existing
ensureWorkspaceInSidebar(workspaceId, projectId)helper, which upserts both the workspace and its parent project intov2SidebarProjectsandv2WorkspaceLocalState— so workspaces in un-pinned projects just work without any new pinning UI. Opening a workspace navigates to/v2-workspace/$workspaceId, and the existingv2-workspace/layout.tsxhandles local-vs-relay URL resolution uniformly for local, remote, and cloud hosts.File layout (co-located under
v2-workspaces/per AGENTS.md)All UI is built from
@superset/uishadcn primitives (Item,ItemGroup,Badge,Empty,InputGroup,ToggleGroup,Button,ScrollArea,Tooltip).Manual QA Checklist
Discovery page content
v2_hosts.isOnlineis stalecreatedAtrenders relative time without crashing (string → Date normalization)Row actions
Sidebar nav entry
DashboardSidebarHeadershows a "Workspaces" button withLuLayersicon above "New Workspace" (expanded variant)/v2-workspacesFilters + search
Not verified (noted for reviewer)
isOnline=falseon a remote host in my dev setupTesting
bun run lint— cleanbun run typecheck— clean across all packagesbun run test—@superset/desktop1629 pass / 0 fail (via turbo)bunx sherif— no issuesDesign Decisions
v2_users_hostsrather 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.renderProjectGroupshelper.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
v2_users_hostsinner join. The stalev2WorkspaceLocalStateentry in the sidebar is a pre-existing issue, not introduced here.createdByUserIdis set but no matchingusersrow is synced yet.Follow-ups
Risks / Rollout
ensureWorkspaceInSidebar/removeWorkspaceFromSidebarhelpers that write to localStorage.Summary by cubic
Adds a v2 workspace discovery page at
/v2-workspacesto 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
/v2-workspaces.useLiveQueryjoin; access filtered byv2_users_hosts; host type derived from localmachineId; uses existingensureWorkspaceInSidebarandremoveWorkspaceFromSidebar.Bug Fixes
role="button",tabIndex=0) for proper accessibility.useAccessibleV2Workspacesapplies search before deriving counts).Written for commit 41a5411. Summary will update on new commits.
Summary by CodeRabbit