Skip to content

fix(web): harden breadcrumb reveal state

MacroscopeApp / Macroscope - UI Consistency failed Aug 16, 2026 in 10m 4s

UI Consistency: 1 issue found

apps/web/src/components/files/FileBrowserPanel.tsx — the project-root breadcrumb reveal derives its scroll/focus target from entries[0], which is the server's flat lexicographic listing (files and directories interleaved), not the tree's visible row order; it can focus a mid-list row instead of scrolling the tree to the top. On the same path visibleItemPath is null, so closeSearch() and the deselect loop run while nothing is selected, dropping the open file's tree highlight for no gain. Inline comment posted with the suggested fix.

The two findings from the previous run are resolved: the crumb button now pins text-left, and the reveal effect resolves and validates the visible target before mutating search/selection/focus.

Details

Note

Your check run agent prompt is: .macroscope/check-run-agents/ui-consistency.md
More information about how Check Run Agents work can be found in our Help Center.

Files examined: apps/web/src/components/files/FileBrowserPanel.tsx, apps/web/src/components/files/FilePreviewPanel.tsx, apps/web/src/components/files/filePath.ts, plus apps/web/src/components/ui/* for focus-ring and control conventions.

Checked and considered acceptable:

  • Raw <button> for a breadcrumb segment: geometry (no control height, max-w-40 truncate, xs text inside a 40px/28px subheader) differs from Button, so it is not a reconstructed primitive; it keeps cursor-pointer, a focus-visible ring, title, and an aria-label.
  • Hover treatment (hover:bg-accent hover:text-foreground) matches the ghost-trigger convention used in select.tsx.
  • Missing focus-visible:ring-offset-1 focus-visible:ring-offset-background relative to the shared primitives is a cosmetic difference only; not reported.
  • visibleDirectoryTreePath re-implements Pierre's flattenEmptyDirectories rule in app code. @pierre/[email protected] is not vendored, so the flattened-row identity could not be verified at source level; since the helper avoids the focus-mutating focusNearestPath probe flagged previously and matches the documented rule, it was not reported.
  • A reveal request that never resolves (path outside the entries index, e.g. ignored or beyond the 25,000-entry cap) is never cleared and stays pending in FilePreviewPanel state. Impact is low (the target stays unresolvable), so it was noted here rather than commented on.

🔍 Agent activity — 1 tool call

💭 Checking the PR diff between the previous commit and the current head — the first issue with text-left appears to be resolved, and the second issue around closeSearch validation looks fixed too since the reveal effect now resolves visibleItemPath before mutating. Let me examine the current FileBrowserPanel effect and FilePreviewPanel breadcrumb implementation to verify. Looking at the logic flow, when breadcrumbRevealPath is null, the function returns early. For an empty string path at the root, it's falsy but distinct from null, so the default entry kind becomes "directory" with an empty item path and null item. The critical check at line 370 evaluates whether we have both a breadcrumb path and no matching item, or conversely no breadcrumb path and no root item path—if that second condition holds, the function returns. For the root reveal case, visibleItemPath becomes null and we scroll to rootItemPath, which closes the search and deselects everything before scrolling—arguably useful despite being somewhat destructive. When handling a directory case, the function calls visibleDirectoryTreePath with the breadcrumb path, but I need to account for how Pierre flattens directory chains. When a directory has only one child directory, Pierre collapses them into a single row displaying the full path like "src/components" as one row. The registered path for that flattened row would be the deepest path in the chain, so clicking a breadcrumb like "src" would actually resolve to the flattened row's full path "src/components/". For the ancestor expansion loop, it expands each segment in the breadcrumb path, including the target directory itself. But here's the thing—for a flattened chain, expanding just the segments in the breadcrumb path won't expand the deepest flattened directory; the visible row might remain collapsed even though we're selecting and scrolling to it. That's probably acceptable since selecting a collapsed directory is fine, but I should check the FilePreviewPanel code to see if there's a UI consistency issue with how the breadcrumb button behaves. I'm thinking through whether a raw button element in a breadcrumb crumb would violate the style guide's preference for using the Button component. The crumb is just inline text with hover and focus states, and since it has different styling constraints than Button—like truncation and no fixed height—it might not actually be recreating the component's functionality. Let me check if there's already a Breadcrumb primitive in the UI library and what the repo's standard focus ring conventions are, since the added focus-visible classes might not match the existing pattern used elsewhere. I'm noticing the new button's focus ring styling differs from the repo convention—it's missing the ring offset and uses outline-none instead of the standard approach. These are minor aesthetic differences, and while consistency would be nice, they probably don't warrant blocking the change. The bigger concern is that the breadcrumb crumbs are now focusable tab stops inside a ScrollArea with fade masking, which means the focus ring could get clipped at the edges when scrolling. Also, keyboard focus now triggers scrolling within the breadcrumb strip. The current file crumb being a button that calls revealBreadcrumb is fine, and the title attribute was already there before. Looking at the fix for issue #2, the closeSearch now happens after visibleItemPath validation, but there's still an ordering gap in the root reveal case that needs checking. I need to understand how Pierre's flattening works with the directory tree — specifically whether it flattens at the root level and how the flattened row paths align with what visibleDirectoryTreePath expects when walking down the chain. Looking at a concrete example: if src/ has only one child src/components/, and that directory contains files, the displayed row shows "src/components". When I click the breadcrumb for src, visibleDirectoryTreePath correctly returns "src/components/" because that's the deepest flattened directory. The key insight is that flattening only continues while a directory's sole direct child is another directory — once there are files mixed in, the flattening stops. I'm checking whether entries represents the full recursive project list or just lazily-loaded expanded directories. If it's lazily loaded, filtering for direct children might return zero results, which would still correctly return the current path with a trailing slash. I'm also reviewing UI consistency details like the breadcrumb button's hover states matching the app's conventions with accent background and foreground text color. The button includes an explicit cursor-pointer class and no disabled states. I'm considering whether a coarse-pointer hit target is needed here, though this isn't a migration scenario. The aria-label correctly overrides the accessible name so screen readers announce "Reveal src in file explorer" as intended. Now I'm examining potential focus ring clipping issues since the breadcrumb button sits inside a ScrollArea viewport with overflow-auto. Looking at the dependency array in the FileBrowserPanel effect — entries is derived from the query data and becomes a new empty array each render when data is undefined, which could cause the effect to re-run unnecessarily even though the early return on breadcrumbRevealPath being null should prevent most issues. I need to trace through whether this actually causes problems when data is present versus absent. The selectedPath effect interaction is also worth checking: after the breadcrumb reveal selects a directory, the selectedPath effect might try to re-select the open file, but it should early-return because the handled ref will match the same path and reveal ID. The tricky case is if entries change between renders. For the current file breadcrumb click scenario, the logic looks sound — it expands ancestors, selects the file, and scrolls to it. But I should verify whether there's an edge case I'm missing with how the reveal state interacts with subsequent renders. Now I need to check the tree's selection callback to see if syncingSelectionRef properly guards against programmatic selects triggering an onOpenFile call, and whether directory selection could accidentally open a file. I'm noticing that model.closeSearch() gets called during the reveal effect, but the search input value lives in React state through useFileTreeSearch(model). The selectedPath effect follows the same pattern, so it seems like an established approach. When the user clicks a breadcrumb while a tree search is active, it closes the search and reveals the path. There's a potential issue with the directory ancestor expansion when dealing with flattened chains. If the target is src/components and src flattens into it, the effect selects and scrolls to src/components/, but the loop only expands ancestors based on src segments — so it expands src/ but might not handle the flattened row correctly. The real question is whether the flattened row is registered under the topmost path (src/) or the deepest path (src/components/). If it's registered under the topmost path but visibleDirectoryTreePath returns the deepest path, then model.getItem(visibleItemPath) would fail, causing an early return and breaking breadcrumb navigation in flattened directories. I need to search the repo for the flattenEmptyDirectories definition to understand how the flattened rows are actually registered. I can't verify this externally since the code itself is the only evidence, which is circular reasoning. The guidance is clear: if I can't prove ownership or semantic equivalence, I should ask for evidence rather than guess about Pierre's internals. Let me shift focus to what I can actually evaluate in the diff—looking for UI consistency issues or potential regressions. The reveal effect when the breadcrumb root is clicked (breadcrumbRevealPath === "") closes search and deselects everything, but doesn't select anything in return and has no selection restore logic. More critically, after deselecting the currently open file's row, the selectedPath reveal effect won't re-select it because handledRevealRef already matches the state, so the open file loses its tree highlight permanently until it's reopened. That's a real UI regression—clicking the project-root crumb causes the tree to lose the "current file" highlight. Though directory crumb clicks also move selection to that directory row (which mirrors VS Code's reveal behavior), the root case leaves selection empty entirely. Deselecting for root might not be necessary; scrolling to the top without deselecting would probably be better. This feels like a moderate behavioral concern worth flagging. I'm also checking the accessibility and styling details: the crumbs have aria-label overrides but no aria-current for the current file crumb, which isn't a stated violation. The button styling is missing the focus-visible:ring-offset-1 focus-visible:ring-offset-background pattern used elsewhere in the repo and uses focus-visible:outline-none instead of the standard outline-none, which could cause focus ring issues inside the masked ScrollArea. I should verify whether there's an existing breadcrumb primitive or pattern in the codebase that this raw button should follow, and I'm also noticing that clicking a crumb inside the horizontal ScrollArea might trigger an unintended scroll behavior — similar to the earlier regression where selecting a file clears the highlight. adcrumb-like button patterns elsewhere. The mobile app's breadcrumbs are out of scope, so I'm focusing on the FilePreviewPanel. When revealBreadcrumb opens the explorer, FileBrowserPanel remounts around line 1090, but if its entries query is still pending on mount, it defaults to an empty array and the effect exits early without completing the necessary setup. Once the data arrives, the effect runs again with populated entries and the reveal proceeds — the breadcrumbReveal state persists until handled, which is good. But I need to verify whether FileBrowserPanel only renders when explorerOpen is true, since there might be separate render paths for panel and inline modes, and only one might have the right props wired up. Let me search for all FileBrowserPanel occurrences to check. Looking at whether there's a UI consistency issue worth addressing—specifically checking if the button styling and truncation behavior matches how similar clickable elements are handled elsewhere in the codebase, and whether the inline-block button with truncation and max-width constraints works properly within a flex container. The button as a flex item handles height and alignment fine with items-center. The hover states are consistent with the select ghost pattern. The focus ring offset is more of an aesthetic consideration. The real question is whether this should use the Button component with ghost variant and xs size, but that would impose fixed height and padding that doesn't fit the breadcrumb's geometry—the crumbs are intentionally text-xs with no fixed height to keep them compact in the header. Let me check what actually changed in the recent commit to see if a new issue was introduced. I'm checking the new code for potential issues. The onBreadcrumbRevealHandled callback clears the breadcrumb reveal state, which is fine, but there's a subtle timing problem: calling this function during the effect body triggers a parent state update and re-render, and I need to trace through what happens next since the thought cuts off mid-sentence. The entries array might not match the tree's row ordering — if the server sorts differently than the UI projection, entries[0] could point to an arbitrary row rather than the actual top. For the root reveal case, when the topmost entry is a directory with nested directories, the path gets flattened to the deepest child, which is mostly correct. The root reveal also clears search and deselects the open file's highlight while moving focus into the tree. For directory reveals, the ancestor expansion loop expands the target directory itself, which differs from just selecting it — and if the target is a flattened hidden item in Pierre's tree, the expand call might do nothing or leave inconsistent state. When the target directory has no children, the path correctly returns with a trailing slash. The flattenEmptyDirectories option seems designed to flatten directories containing only a single subdirectory. The O(n) filtering per level should be fine since the reveal typically spans just a few directory levels. I'm checking whether the effect runs before entries load and leaves a pending request if the target path isn't in the entries list — like a gitignored file outside the scope. If that happens, the breadcrumbReveal state never clears, so the request stays pending indefinitely. Later, if entries refresh and suddenly includes that path, the stale reveal fires and hijacks focus to an old location, potentially long after the user clicked it — exactly the kind of stale navigation replay that's problematic. Since FilePreviewPanel maintains state per panel, switching files doesn't clear breadcrumbReveal either. The guidance emphasizes clearing the request after success, but a pending-forever request on failure is a latent issue that could silently cause problems. I'm wondering whether the reveal commonly no-ops in practice — specifically, whether the server's project entries include the full recursive tree and respect .gitignore, since that determines whether a gitignored file opened via preview would be resolvable later. The entries are sorted lexicographically by full path with files and directories mixed together, not directories-first like Pierre's tree renderer would display. So entries[0] is the alphabetically first path overall—something like ".github" or ".editorconfig"—rather than the first item at the top level after directory grouping. If .editorconfig comes before .github lexicographically and the tree renders directories above files, then scrolling to entries[0] would land on a file somewhere in the middle of the list, not at the actual top of the tree. That means clicking the project-name crumb wouldn't reveal the root as intended—it'd jump to the wrong position. The real issue is that entries[0] doesn't correspond to the first rendered row in the tree's visual projection, so clicking the project crumb could scroll to an arbitrary position instead of the top. A better approach would be to use the tree's own viewport scroll method or derive the first visible row from the model's projection rather than relying on the filesystem entries array, especially since I'm uncertain whether Pierre sorts directories first or how it handles the sort order. The root case is problematic regardless of sorting: if I call visibleDirectoryTreePath on the root directory with entries[0] being something like .github, I'm trying to find a path that doesn't exist in that entry's subtree. I should search the repo for any tests or snapshots showing how the tree orders entries—whether it's directories-first or something else—to understand what the actual scroll target would be. olved reveal request that never gets cleared can linger and fire later when entries refresh, causing the tree to jump to a stale path and steal focus—especially problematic if the user opens another file in the meantime. The pending-until-resolvable behavior is intentional for the mount case when the explorer is loading, but requiring immediate acknowledgment after success might replay stale navigation in other scenarios. A reasonable fix would be to clear the request once entries have loaded but the target still can't be resolved, keeping it pending only while the query itself is pending. That's a concrete, actionable suggestion, though it's marginal whether it's worth failing the check on this third round of findings. Let me dig deeper into the root-crumb case by checking whether the package sorts directories first—I'd need to look at the published typings or check the repo's lock file for any relevant patches. I should also search the tests for evidence of how the file tree orders entries. I'm noticing a discrepancy in the validation approach. The previous code used model.focusNearestPath() to resolve paths through the visible projection, but the new implementation relies on model.getItem(visibleItemPath) which is registration-based rather than visibility-based. This violates the repo's guidance about not assuming registered items are visible—I need to resolve the target through the tree's visible projection before selecting or focusing. The author's hand-rolled reimplementation of the flattening logic was a reasonable response to my earlier concern about mutation, but it duplicates library internals and becomes brittle if Pierre's flattening rules change. I'm caught between two problems: focusNearestPath mutates focus (which I flagged before), but the pure reimplementation duplicates internal logic. There might be a better path forward—either computing the visible projection directly from the model's row list API if available, or performing the reveal operation differently. Looking at the remaining findings, the root crumb reveal is worth flagging: it uses entries[0] as the "top row" to focus, but since entries are sorted lexicographically with files and directories interleaved, that first entry could be a file rendered well below the directory rows in the actual tree view. This means the reveal might scroll to an arbitrary mid-list position instead of the tree's top, and it also clears the selection without selecting anything in its place. I think this deserves an inline comment. The fix would be straightforward—for the root case, either scroll to the first visible row through the model or keep the selection intact and just scroll the viewport to the top. Since this is new functionality (the project crumb wasn't clickable before), it's a bug in the new feature rather than a regression, which still makes it worth reporting. I'm fairly confident that Pierre's file tree uses a dirs-first ordering like most modern editors, so entries[0] likely isn't the first visible row, though I can't verify it directly. For the second issue with the pending-forever unresolvable reveal, it's more of a state-hygiene concern than a UI consistency problem. I should probably mention it briefly alongside the first finding, or combine them into one note. The focus stealing on explorer open is intentional—when a breadcrumb is clicked, the reveal effect moves focus into the tree row as part of the reveal-in-explorer behavior. After that, keyboard focus stays within the shadow DOM tree, which is fine. I'm now thinking through the accessibility implications of this design. Looking at the button's labeling, it has both title and aria-label, which is acceptable. The hover state styling and dark/light mode support look solid. But there's a consistency issue with the focus ring styling—the breadcrumb is missing the ring-offset that every other control in the codebase uses, so the ring will sit directly on the padded box and potentially get clipped by the ScrollArea's overflow at the edges, making it look visually tighter than the rest of the UI. The real concern is that when a crumb near the edge gets focus, the scrollFade mask fades it out, and even though the browser scrolls it into view, the combination of clipping and the opacity gradient creates a small accessibility visibility issue. Since this is a new control and the ring is still technically visible, I could skip flagging it as a hard failure, but it's worth noting the inconsistency. I'm deciding to post one focused inline comment on the root-reveal entries issue, and include the selection-clearing observation there since they share the same code path. The question is whether clearing selection when revealing the root folder is even the right behavior—VS Code selects the folder itself on reveal, but since the project root has no row, the current file's highlight gets lost, which is a mild UX downside worth mentioning. For the fix, I'll explain that the root crumb path derives the scroll target from entries[0] (which is lexicographically ordered with files interleaved) rather than the tree's visible projection, so it can focus a mid-list row. The smallest fix would be to scroll the tree viewport to the top for the empty path case and skip the deselect loop when there's no target to select. Now I'm weighing whether this counts as a fail or success—it's a moderate finding with an unverifiable premise about the ordering, so I'll post the comment. Actually, I need to be careful about false positives. The check's spirit is to report concrete violations, and the repo's own guidance explicitly warns against assuming every registered item path is currently visible—you must resolve the target through the tree's visible projection. Using entries[0] directly violates that stated rule, regardless of the sort order details. That's defensible enough to mark as failure with an inline comment. I'm placing the comment on line 369 where rootItemPath is assigned. The comment should explain that entries[0] pulls from the server's flat lexicographic listing where files and directories are interleaved, so it's only the tree's first row if...

1. modify_pr — One finding on the new breadcrumb reveal: the project-root crumb resolves its scroll/focus target from raw array position instead of the tree's visible projection, and clears selection with nothing to select. Directory/file crumbs and the text-left fix from the previous round look good.


Agent Credits: 541 credits