Skip to content

feat(sidebar): hover details card for workspaces - #208

Open
Zeus-Deus wants to merge 2 commits into
mainfrom
sidebar-workspace-hover-tooltip
Open

feat(sidebar): hover details card for workspaces#208
Zeus-Deus wants to merge 2 commits into
mainfrom
sidebar-workspace-hover-tooltip

Conversation

@Zeus-Deus

Copy link
Copy Markdown
Owner

Why

Every sidebar surface is lossy. An inbox card truncates its title and never renders git_behind or git_changed_files at all — both already ride on WorkspaceSnapshot and were displayed nowhere. A settled row shows only a title. A collapsed rail avatar shows nothing but a status dot, behind a title-only tooltip.

Resting the pointer on any of the three now opens a shared read-only HoverCard to the right (350ms open, 290px, side="right" align="start").

What it shows

Header — repo eyebrow, provider marks, agent state with elapsed, the full untruncated title, and the linked issue's number + title.

Rows, all conditional except Location — Branch, Uncommitted +A −D, Changed files, Ahead, Behind (or a single "Working tree · clean" row when there is nothing to report), Pull request, Issue, Port(s) capped at 3 with a +N overflow, Location (This device / host name / host · in place), Notifications muted.

Footer — the real path on disk (worktree_path ?? remote_cwd ?? cwd), $HOME collapsed to ~, wrapped rather than truncated.

No new Tauri command. Every field is already on WorkspaceSnapshot or in appState.detected_ports. Styling reuses the DetailRow label/value shape from the context bar's popover rather than inventing a second vocabulary.

Implementation notes

  • The body is a separate component so Radix's unmount-when-closed keeps a sidebar of N workspaces from subscribing N times to the ports/hosts stores at rest.
  • Ports are selected as a raw slice and narrowed in a useMemo. Filtering inside the zustand selector returns a fresh array per call, which breaks useSyncExternalStore snapshot equality and spins React into an infinite re-render loop — this crashed the card tree during development and was caught only by running the real app.
  • Remote-ness keys off host_id alone. The hosts list loads asynchronously, so an unresolved name falls back to "Another device" rather than letting a failed lookup claim the workspace is local.
  • Mounted inside WorkspaceInboxMenu on a different node than ContextMenuTrigger, so the two asChild triggers never compose onto one element.
  • The label holds its width and the value truncates. The reverse rendered long worktree branches as Bran… next to the thing you came to see.
  • The card is pointer-events-none — a detail surface, not an interactive one. Reviewers may want to overrule this: it means you cannot mouse in to select the branch name or path.

Removes the rail's title-only tooltip and the card's redundant native title attribute, which would have raced the hover card on the same element.

Verification

  • npm run check clean.
  • npm run test — 210 files, 3069 tests pass, including 19 new ones covering the git rows, clean-tree fallback, non-git workspaces, PR/issue tones, port capping, the async-host fallback, mute, and path shortening.
  • Visually confirmed in the dev build across all three surfaces (inbox card, settled row, collapsed rail), with instrumented pointerenter proving real mouse input opens it.
  • Zero raw Tailwind palette utilities — status/accent tokens only, per the design system.
  • npm run verify does not complete in this worktree: cargo check fails on a missing src-tauri/binaries/agent-browser sidecar. Pre-existing environment gap, unrelated — this branch touches no Rust.

Docs updated in docs/features/sidebar.md (inbox section, rail section, What Works, Constraints, Touch Points).

The sidebar is lossy on every surface: an inbox card truncates its title
and never renders git_behind or git_changed_files at all, a settled row
shows only a title, and a collapsed rail avatar shows nothing but a status
dot. Resting the pointer on any of the three now opens a shared read-only
HoverCard to the right (350ms open, 290px, side="right" align="start").

Contents, all conditional except the header and Location: repo eyebrow +
provider marks + agent state with elapsed, the full untruncated title, the
linked issue, then label/value rows — Branch, Uncommitted, Changed files,
Ahead, Behind (or a single "Working tree clean" row when there is nothing
to report), Pull request, Issue, Port(s) capped at 3 with a +N overflow,
Location, Notifications muted — and finally the real path on disk with
$HOME collapsed to ~.

No new Tauri command: every field already rides on WorkspaceSnapshot or
appState.detected_ports. Reuses the DetailRow label/value shape from the
context bar's popover.

Implementation notes:

- The body is a separate component so Radix's unmount-when-closed keeps a
  sidebar of N workspaces from subscribing N times to the ports/hosts
  stores at rest.
- Ports are selected as a raw slice and narrowed in a memo; filtering
  inside the zustand selector returns a fresh array per call, which breaks
  useSyncExternalStore snapshot equality and spins React into an infinite
  re-render loop.
- Remote-ness keys off host_id alone. The hosts list loads asynchronously,
  so an unresolved name falls back to "Another device" rather than letting
  a failed lookup claim the workspace is local.
- Mounted inside WorkspaceInboxMenu on a different node than
  ContextMenuTrigger so the two asChild triggers never compose onto one
  element.
- The label holds its width and the value truncates; the reverse left long
  worktree branches rendering as "Bran…" next to the thing you came to see.

Replaces the rail's title-only tooltip and the card's redundant native
title attribute, which would have raced the card on the same element.
@Zeus-Deus

Copy link
Copy Markdown
Owner Author

Review — Opus 5 (high effort)

Solid, and the two hardest details are handled correctly:

  • The ports-selector trap is genuinely avoided. Selecting the raw slice and narrowing in useMemo is the right shape; a filtering selector really would break useSyncExternalStore snapshot equality and spin. Good that this is written down in the code, not just the PR body.
  • The two asChild triggers are on distinct nodes in all three call sites, which is the failure mode I'd expect from this composition.
  • Remote-ness keying off host_id alone with an "Another device" fallback is the right call given the async hosts load.

pointer-events-none contradicts the footer's own rationale

{/* Real path on disk, last: the least-scannable line, and the one users
    most often want to copy or confirm. Wraps rather than truncates. */}

…inside a card that is pointer-events-none, so it cannot be selected or copied. You already flagged this for reviewers, so: I'd overrule it. The path and the branch name are exactly the two values a user reaches for a hover card to get, not just to read. Radix HoverCard handles mouse-into-content fine (that's what closeDelay is for), and dropping the class costs nothing except that the card intercepts clicks in its own footprint — which is over the main pane, not over other sidebar rows. If it stays non-interactive, the footer comment should stop claiming users want to copy it.

Settled rows always report "Idle"

SettledRow passes status={null} unconditionally, so the header renders Idle even for a settled workspace whose agent is actively working — and #206 makes "settled but working" a real, reachable state (its auto-un-settle fires on working). The row itself shows no status either, so this isn't a regression, but the hover card is the surface whose whole purpose is to un-lose what the row dropped. getWorkspaceStatus(workspace.surfaces, paneStatuses) is already available.

Two smaller notes

  • elapsed freezes at open. formatElapsed(Date.now() - stateSince) is computed once per mount, deliberately. Fine for a 350ms-to-open card you glance at — but it's the one value on the card that visibly goes wrong if you leave the pointer resting, and "Working 4m" that stays 4m for two minutes reads as a bug.
  • The bare <div> wrapper in SettledRow now sits between the settled list's flex container and the row, so it becomes the flex item. Almost certainly fine, but it's an unlabelled layout node — worth a contents-style comment or className="contents" if the row was relying on being a direct child.

@Zeus-Deus

Copy link
Copy Markdown
Owner Author

Review by GPT 5.6 Sol xhigh found a path-boundary bug.

shortenPath at workspace-hover-card.tsx:48-50 uses raw path.startsWith(homeDir). That treats sibling prefixes as descendants: with home /home/u, a workspace at /home/u2/project is rendered as ~2/project. The same issue occurs with trailing separators and platform-specific roots.

Please compare path components (or require the next character to be a separator, while handling exact-home as a special case) and add the sibling-prefix regression. The focused hover-card suite passed 19/19 tests; it currently covers an actual child and a completely unrelated path, but not this boundary.

Coordination note: this head has pairwise textual conflicts with #206 and #213.

- shortenPath now requires a real path-separator boundary after $HOME,
  so a sibling like /home/u2/project no longer renders as ~2/project;
  exact-home collapses to a bare ~ and a trailing separator on the
  reported home dir is tolerated (plus \ for Windows-style hosts)
- drop pointer-events-none from the card content so the path and branch
  can be selected and copied; Radix already holds the card open while
  the cursor crosses into it, and side="right" floats it over the main
  pane rather than other sidebar rows
- settled rows now pass the workspace's computed agent status to the
  hover card instead of hard-coding null, so a settled 'review'
  workspace no longer reads Idle
- the elapsed label ticks on the shared coarse (~30s) clock while the
  card is open instead of freezing at its mount-time value
- document the bare settled-row wrapper div as an intentional
  layout-neutral trigger node

Adds regression tests for the sibling-prefix path, exact-home and
trailing-separator cases, the settled-row status pass-through, and the
ticking elapsed label.
@Zeus-Deus

Copy link
Copy Markdown
Owner Author

Verified: all five review issues are genuinely fixed at the head (pointer-events restored on the card, settled rows pass real status, elapsed ticks via the shared coarse clock, shortenPath sibling-prefix/exact-home/trailing-separator cases covered). tsc clean and all touched suites pass (23/23 hover-card, 30/30 sidebar-inbox, 6/6 rail).

Blocked on a rebase now that #206 merged — conflicts in sidebar-inbox.tsx, sidebar-inbox.test.tsx, sidebar-inbox-card.tsx, sidebar-rail-workspaces.tsx, docs/features/sidebar.md. Should be mostly textual; one semantic point: main's settling work adds new tiers/states for settled rows, and this PR's settled-row status prop should reflect those (the hover card anticipated exactly this, so integration should be natural).

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