Skip to content

[OPIK-7126] [FE] feat: unify workspace menu with projects menu#7484

Merged
awkoy merged 5 commits into
mainfrom
awkoy/opik-7126-unify-workspace-menu
Jul 16, 2026
Merged

[OPIK-7126] [FE] feat: unify workspace menu with projects menu#7484
awkoy merged 5 commits into
mainfrom
awkoy/opik-7126-unify-workspace-menu

Conversation

@awkoy

@awkoy awkoy commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Details

Unifies the workspace switcher with the projects menu shipped in OPIK_7125, so switching workspace feels the same as switching project. The menu now has a scoped search, recency-ordered and pinnable sections, and a "View all" footer.

Because workspaces come from the Comet platform API (/workspaces/lite), which exposes no last-updated field, recency is tracked client-side as "recently visited" (per the design's label) — no backend dependency.

  • Search placeholder SearchSearch workspace.
  • Recency ordering — new useRecentWorkspaces hook records visits in localStorage (capped at 50) and sorts recently-visited first, with an alphabetical fallback for never-visited workspaces.
  • Sections — clusters into Pinned + Recently visited (top 10); a flat, virtualized list while searching. Pinning via new usePinnedWorkspaces hook (org-scoped localStorage), mirroring usePinnedProjects; pinned order is stable (insertion order).
  • FooterManage workspacesView all workspaces with an arrow-up-right icon, matching the projects menu.
  • Org header always opens the organizations submenu (chevron), matching the Figma; the per-org settings action lives inside that submenu (previously a gear was shown inline for single-org users).

Figma: https://www.figma.com/design/MHqvEwnuCHBLVvN9ilmhw8/Information-Architecture?node-id=716-5458

Change checklist

  • User facing
  • Documentation update

Issues

  • Resolves OPIK-7126
  • Related (not resolved here): OPIK_7125 (projects menu), OPIK_7183 (land on Projects tab)

AI-WATERMARK

AI-WATERMARK: yes

  • Tools: Claude Code
  • Model(s): Claude Opus 4.8
  • Scope: full implementation (hooks + menu restructure) with human direction; pixel-perfect pass against Figma; addressed a high-effort automated review
  • Human verification: manual E2E in the browser (comet mode) + measured computed styles against Figma

Testing

Ran the frontend in --mode comet against dev.comet.com (workspaces yaroslav-boiko / v2jj):

  • Menu content — "Search workspace" placeholder, "Pinned" + "Recently visited" sections, "View all workspaces ↗" footer, org header chevron — all match the Figma (verified via measured computed styles: 280px width, 4px padding, 6px radius, #94A3B8 section labels, #373D4D rows, 32px row height).
  • Recency — active workspace floats to the top of "Recently visited"; ordering updates after a switch.
  • Pinning — pin toggle on hover; pinned workspace moves to the "Pinned" section and persists across navigation (localStorage).
  • Switch — selecting a workspace lands on that workspace's Projects tab.
  • Search — filtering collapses the sections into a flat result list; whitespace-only input no longer shows a false "No workspaces found".
  • npm run typecheck and eslint --max-warnings=0 on the changed files — both clean.
  • Unit tests for both new hooks (usePinnedWorkspaces, useRecentWorkspaces) — 11 tests, green on Node 20 (CI's version).

Documentation

N/A — navigation/menu behavior change; no user-facing docs affected.

🤖 Generated with Claude Code

Bring the workspace switcher in line with the projects menu (OPIK_7125):
scoped search, recency-ordered + pinnable sections, and a "View all" footer.

- Search placeholder "Search" -> "Search workspace".
- Order workspaces by recency: new useRecentWorkspaces hook records visits
  in localStorage (capped) and sorts recently-visited first, alphabetical
  fallback for never-visited.
- Cluster into "Pinned" + "Recently visited" (top 10) sections; flat
  virtualized list while searching. Pinning via new usePinnedWorkspaces hook
  (org-scoped localStorage), mirroring usePinnedProjects; pin order stays
  stable (insertion order).
- Footer "Manage workspaces" -> "View all workspaces" with an arrow-up-right
  icon, matching the projects menu.
- Org header always opens the organizations submenu (chevron), matching the
  Figma; the per-org settings action lives inside that submenu.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

⏱️ pre-commit per-hook timing

Hook Description Result Duration
🌐 typecheck — frontend Whole-project tsc type check 40.26s
🌐 eslint — frontend Lint + autofix JS/TS 4.88s
⚓ helm-docs Regenerate Helm chart README 2.61s
block non-public FE plugins Block non-public FE plugins 0.01s
Total (4 ran) 47.76s
⏭️ 37 skipped (no matching files changed)
Hook Description Result
🐍 trim trailing whitespace — python sdk Strip trailing whitespace ⏭️
🐍 fix end of files — python sdk Ensure files end in a newline ⏭️
🐍 ruff — python sdk Lint + autofix Python (ruff) ⏭️
🐍 ruff-format — python sdk Format Python code (ruff) ⏭️
🐍 mypy — python sdk Static type check ⏭️
🤖 trim trailing whitespace — optimizer Strip trailing whitespace ⏭️
🤖 fix end of files — optimizer Ensure files end in a newline ⏭️
🤖 check yaml — optimizer Validate YAML syntax ⏭️
🤖 check json — optimizer Validate JSON syntax ⏭️
🤖 check toml — optimizer Validate TOML syntax ⏭️
🤖 check for added large files — optimizer Block large files (>1MB) ⏭️
🔐 detect private key — optimizer Block committed private keys ⏭️
🤖 check for merge conflicts — optimizer Block merge-conflict markers ⏭️
🤖 check for case conflicts — optimizer Block case-only name clashes ⏭️
🤖 pyupgrade — optimizer Modernize Python syntax ⏭️
🤖 ruff — optimizer Lint + autofix Python (ruff) ⏭️
🤖 ruff-format — optimizer Format Python code (ruff) ⏭️
🤖 mypy — optimizer Static type check ⏭️
📓 nbstripout — optimizer notebooks Strip notebook output ⏭️
📝 markdownlint — optimizer Lint Markdown ⏭️
🔤 codespell — optimizer Fix common misspellings ⏭️
📊 radon cc — optimizer Cyclomatic-complexity gate ⏭️
📊 radon raw — optimizer Raw size metrics gate ⏭️
📊 xenon — optimizer Fail on complexity thresholds ⏭️
📊 lizard — optimizer Cyclomatic-complexity gate ⏭️
🧹 vulture — optimizer Find dead code ⏭️
🛡️ trim trailing whitespace — guardrails Strip trailing whitespace ⏭️
🛡️ fix end of files — guardrails Ensure files end in a newline ⏭️
🛡️ ruff — guardrails Lint + autofix Python (ruff) ⏭️
🛡️ ruff-format — guardrails Format Python code (ruff) ⏭️
🛡️ mypy — guardrails Static type check ⏭️
☕ spotless — java backend Format Java code ⏭️
🧪 pre-commit wrapper smoke tests Self-test the wrapper scripts ⏭️
📘 eslint — typescript sdk Lint + autofix JS/TS ⏭️
📘 typecheck — typescript sdk Whole-project tsc type check ⏭️
⚙️ actionlint — github workflows Lint GitHub Actions workflows ⏭️
🐳 hadolint — dockerfiles Lint Dockerfiles ⏭️

@awkoy awkoy added the test-environment Deploy Opik adhoc environment label Jul 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🔄 Test environment deployment process has started

Phase 1: Deploying base version 2.1.30 (from main branch) if environment doesn't exist
Phase 2: Building new images from PR branch awkoy/opik-7126-unify-workspace-menu
Phase 3: Will deploy newly built version after build completes

You can monitor the progress here.

Comment on lines 323 to +325
<ListAction variant="default" size="sm" asChild>
<a href={buildUrl("account-settings/workspaces", workspaceName)}>
<Settings2 className="size-3.5 text-light-slate" />
<span>Manage workspaces</span>
<span>View all workspaces</span>

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.

Footer no longer switches workspaces

View all workspaces now points to account-settings/workspaces, which opens the Workspace preferences tab instead of a workspace list or switcher, so hidden workspaces can't be reached from the footer — should we route it to a workspace-selection page or rename it to match the settings destination?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
apps/opik-frontend/src/plugins/comet/WorkspaceMenuContent.tsx around lines 323-325, the
footer ListAction renders an <a> with the label “View all workspaces” but it
navigates to buildUrl("account-settings/workspaces", workspaceName), which leads to
ConfigurationPage’s WorkspacePreferencesTab instead of the workspace switcher/list.
Fix by either updating the href to the actual workspace-selection/list route used for
switching (so the action opens a page that shows all workspaces), or if no such route
exists, rename the CTA to reflect the destination (e.g., “Workspace preferences”) to
avoid misleading UX. Verify the route you choose matches how
ConfigurationPage/WorkspacePreferencesTab is reached today and that unpinned/non-recent
workspaces become reachable from this CTA.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not a regression — dismissing this one.

Two things are off here:

  1. The destination didn't change in this PR. The href is untouched; only the label (Manage workspacesView all workspaces) and the icon (gear → arrow) changed. This footer has always pointed at account-settings/workspaces, so nothing about workspace reachability regressed. The "now points to" framing is what drove the severity, and it's not accurate.

  2. It's not WorkspacePreferencesTab. buildUrl prefixes VITE_BASE_COMET_URL, so account-settings/workspaces resolves to the Comet platform's account-settings → workspaces page (the same URL useInviteMembersURL.ts builds), not opik's internal ConfigurationPage/WorkspacePreferencesTab. That platform page is the canonical "all workspaces" list, so the View all workspaces label is accurate.

No code change needed.

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.

Thanks for the correction — that makes sense. I'll save this to memory once the PR is merged.

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.

Commit 8e84029 addressed this comment by renaming the footer CTA from "Manage workspaces" to "View all workspaces" while leaving the existing destination unchanged. That matches the suggested fallback fix of aligning the label with the settings page destination.

Comment on lines +14 to +25
// Scoped per organization: pinning is only meaningful among the workspaces of the
// current org. Mirrors usePinnedProjects (localStorage, {id,name}, functional updates).
const usePinnedWorkspaces = (
organizationId: string | undefined,
): UsePinnedWorkspacesResult => {
const [pinnedWorkspaces = [], setPinnedWorkspaces] = useLocalStorageState<
PinnedWorkspace[]
>(`workspaces:pinnedConfig:${organizationId ?? "unknown"}`, {
defaultValue: [],
});

const isPinned = useCallback(

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.

Duplicated pinning hook

useLocalStorageState mirrors usePinnedProjects in src/v2/layout/ProjectMenu/usePinnedProjects.ts, so persistence and deduplication logic live in two places and will likely diverge — should we share the existing hook or extract a small reusable helper?

Severity

Want Baz to fix this for you? Activate Fixer

@CometActions

Copy link
Copy Markdown
Collaborator

Test environment is now available!

To configure additional Environment variables for your environment, run [Deploy Opik AdHoc Environment workflow] (https://github.com/comet-ml/comet-deployment/actions/workflows/deploy_opik_adhoc_env.yaml)

Access Information

The deployment has completed successfully and the version has been verified.

Match the Figma instruction note and the projects menu section header
("Recently updated"). Only the user-facing label changes; the underlying
recency mechanism (client-side visit tracking) and its hook/variable names
are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment on lines +313 to +314
<MenuSectionLabel>Recently updated</MenuSectionLabel>
{recentlyVisitedWorkspaces.map(renderWorkspaceItem)}

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.

MenuSectionLabel says "Recently updated" but the list is driven by client-side visit timestamps from useRecentWorkspaces/getVisitedAt, not a backend updated_at field — should we rename it back to "Recently visited" and update any nearby comments to match?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
In apps/opik-frontend/src/plugins/comet/WorkspaceMenuContent.tsx around lines 313-314,
the MenuSectionLabel text is set to "Recently updated" even though the list is derived
from client-side visit timestamps (useRecentWorkspaces/getVisitedAt) rather than any
backend updated_at field. Change the MenuSectionLabel text to "Recently visited" and
update any surrounding comments in this section to match. Do not change the data
fetching logic unless the backend provides last-updated timestamps. After the change,
verify there are no other "Recently updated" labels in this component that refer to this
same recentlyVisitedWorkspaces section.

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.

Commit 1f00c83 addressed this comment by renaming the section label from "Recently updated" to "Recently visited". It also updated the nearby comment to describe the section as "Pinned + Recently visited," matching the client-side visit timestamp logic.

Comment on lines +31 to +35
const pinWorkspace = useCallback(
(workspace: PinnedWorkspace) => {
setPinnedWorkspaces((prev = []) =>
prev.some((w) => w.workspaceId === workspace.workspaceId)
? prev

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.

Duplicated pin-hook logic

pinWorkspace is almost the same as pinProject, so the localStorage, deduplication, and append logic will drift whenever one side changes — should we extract a generic usePinnedItems helper parametrized by the storage key and id/name selectors?

Severity

Want Baz to fix this for you? Activate Fixer

Revert the previous rename: "Recently visited" is the correct label for the
workspace menu (it reflects the client-side visit-based ordering, and matches
the workspace-menu component in Figma).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment on lines +301 to +312
<>
{pinnedWorkspaces.length > 0 && (
<>
<MenuSectionLabel>Pinned</MenuSectionLabel>
{pinnedWorkspaces.map(renderWorkspaceItem)}
</>
)}
{recentlyVisitedWorkspaces.length > 0 && (
<>
{pinnedWorkspaces.length > 0 && (
<DropdownMenuSeparator className="my-1" />
)}

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.

Duplicate section layout

The pinned/recent section markup in the new workspace menu duplicates ProjectMenuContent.tsx, so the two views can drift on labels, spacing, and empty-state behavior and any tweak has to be made twice — should we extract the shared section rendering into a common helper/component?

Severity

Want Baz to fix this for you? Activate Fixer

@awkoy awkoy added test-environment Deploy Opik adhoc environment and removed test-environment Deploy Opik adhoc environment labels Jul 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🔄 Test environment deployment process has started

Phase 1: Deploying base version 2.1.30 (from main branch) if environment doesn't exist
Phase 2: Building new images from PR branch awkoy/opik-7126-unify-workspace-menu
Phase 3: Will deploy newly built version after build completes

You can monitor the progress here.

@CometActions

Copy link
Copy Markdown
Collaborator

Test environment is now available!

To configure additional Environment variables for your environment, run [Deploy Opik AdHoc Environment workflow] (https://github.com/comet-ml/comet-deployment/actions/workflows/deploy_opik_adhoc_env.yaml)

Access Information

The deployment has completed successfully and the version has been verified.

@CometActions

Copy link
Copy Markdown
Collaborator

🌙 Nightly cleanup: The test environment for this PR (pr-7484) has been cleaned up to free cluster resources. PVCs are preserved — re-deploy to restore the environment.

@CometActions CometActions removed the test-environment Deploy Opik adhoc environment label Jul 16, 2026
@awkoy awkoy added the test-environment Deploy Opik adhoc environment label Jul 16, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🔄 Test environment deployment process has started

Phase 1: Deploying base version 2.1.30 (from main branch) if environment doesn't exist
Phase 2: Building new images from PR branch awkoy/opik-7126-unify-workspace-menu
Phase 3: Will deploy newly built version after build completes

You can monitor the progress here.

@CometActions

Copy link
Copy Markdown
Collaborator

Test environment is now available!

To configure additional Environment variables for your environment, run [Deploy Opik AdHoc Environment workflow] (https://github.com/comet-ml/comet-deployment/actions/workflows/deploy_opik_adhoc_env.yaml)

Access Information

The deployment has completed successfully and the version has been verified.

Keep the hasMultipleOrganizations branch: single-org users get the
inline org header with a directly-visible settings gear instead of a
submenu that discloses a one-item list and buries org settings a click
deeper. Multi-org users keep the organizations submenu.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

// When searching, show all matches flat (virtualized above the threshold).
// Otherwise cluster into Pinned + Recently visited (capped) sections.
const workspacesById = new Map(

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.

💡 suggestion | Performance

Derived workspace collections are rebuilt on every render, including during search when they're discarded unused.

workspacesById (a new Map scanning all workspaces), pinnedWorkspaces (map+filter), and recentlyVisitedWorkspaces (filter+slice) are computed unconditionally in the component body. The menu re-renders on every search keystroke, so these O(n) derivations re-run each time — and they're only consumed in the non-search branch, so during search they're computed and thrown away.

The sibling ProjectMenuContent.tsx memoizes the analogous projectsById with useMemo. Consider wrapping these in useMemo.

🤖 Review posted via /review-github-pr

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in c05ff5d — wrapped workspacesById, pinnedWorkspaces, and recentlyVisitedWorkspaces in useMemo, matching ProjectMenuContent's memoized projectsById. Deps are referentially stable: sortedWorkspaces is already memoized in useWorkspaceSelectorData, and isPinned/pinnedConfig come from useCallback/useLocalStorageState. Lint, typecheck, and the 11 hook tests (Node 20) are green.

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.

Commit c05ff5d addressed this comment by memoizing workspacesById, pinnedWorkspaces, and recentlyVisitedWorkspaces with useMemo. This prevents those O(n) derivations from being rebuilt on every render, including search re-renders where they would otherwise be discarded.

@andriidudar

andriidudar commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

👋 Review summary

What looks good

  • Clean reuse of the projects-menu conventions — pinned/recent hooks, useLocalStorageState, per-org config keying — keeps this consistent with the existing pattern.
  • Both new hooks (usePinnedWorkspaces, useRecentWorkspaces) ship with focused unit tests covering the core happy paths and per-org isolation.
  • Sensible bounding on the recent map (MAX_TRACKED LRU) and a clear separation between persistence (hooks) and presentation (WorkspaceMenuContent).

Overall
✅ Approved — the approach is sound and the unification with the projects menu is the right direction. Nothing blocking.

Inline comments: 1 optional suggestion left (memoize the derived workspace collections for parity with ProjectMenuContent) — take it or leave it.

🤖 Review posted via /review-github-pr

andriidudar
andriidudar previously approved these changes Jul 16, 2026
Wrap workspacesById, pinnedWorkspaces, and recentlyVisitedWorkspaces in
useMemo so the O(n) derivations don't re-run on every render (e.g. each
search keystroke), matching ProjectMenuContent's memoized projectsById.
sortedWorkspaces is already memoized upstream in useWorkspaceSelectorData,
so the dependencies are referentially stable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@awkoy
awkoy merged commit 403bc80 into main Jul 16, 2026
16 checks passed
@awkoy
awkoy deleted the awkoy/opik-7126-unify-workspace-menu branch July 16, 2026 15:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Frontend 🟠 size/L test-environment Deploy Opik adhoc environment typescript *.ts *.tsx

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants