From 9d506ce24a96f273fb2c2e5f7422aa0665d2b912 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Tue, 21 Jul 2026 04:03:53 +0200 Subject: [PATCH 1/2] fix(frontend): drive loading skeletons, non-blocking resolve, retry Rework the session-drive summary surfaces (config Files, chat rail, runtime lens) and the Files drawer: - Loading shows the SAME list rendering placeholder rows that morph into real file rows in one AnimatePresence (no skeleton-block -> list jump). - The session cwd and agent mounts resolve independently: whichever answers first renders immediately, the slower reconciles in with a "Loading more..." hint. A blocking skeleton shows only at the initial blank; terminal "No files" waits for every mount to resolve. - A mount failure no longer masks a working session -- the inline list stays clean and a warning badge rides the drawer-trigger folder icon (both modes); the drawer carries the retry ("Try again" in its header for a partial failure, and on the full-failure Alert). - Retry re-runs the mount/count queries; warning tone throughout. --- .../Inspector/lenses/RuntimeLens.tsx | 221 +++++++---- web/oss/src/components/Drives/ContextRail.tsx | 354 ++++++++++++------ .../src/components/Drives/DriveExplorer.tsx | 45 ++- .../src/components/Drives/DriveFileRow.tsx | 173 ++++++++- .../components/Drives/StorageFilesHeader.tsx | 29 +- .../src/components/Drives/StorageSection.tsx | 183 ++++++--- .../src/components/Drives/useSessionDrive.ts | 103 ++++- 7 files changed, 822 insertions(+), 286 deletions(-) diff --git a/web/oss/src/components/AgentChatSlice/components/Inspector/lenses/RuntimeLens.tsx b/web/oss/src/components/AgentChatSlice/components/Inspector/lenses/RuntimeLens.tsx index e8ba4bd80c..1f30aea13b 100644 --- a/web/oss/src/components/AgentChatSlice/components/Inspector/lenses/RuntimeLens.tsx +++ b/web/oss/src/components/AgentChatSlice/components/Inspector/lenses/RuntimeLens.tsx @@ -11,7 +11,12 @@ import {Broadcast, CaretRight, CircleNotch, Database, FolderSimple} from "@phosp import {useSetAtom} from "jotai" import {AnimatePresence, MotionConfig, motion} from "motion/react" -import {DriveFileRow} from "@/oss/components/Drives/DriveFileRow" +import { + DriveFileRow, + DriveRetryButton, + DriveWarningBadge, + SKELETON_ROW_COUNT, +} from "@/oss/components/Drives/DriveFileRow" import {FILE_ITEM_VARIANTS, FILE_SPRING} from "@/oss/components/Drives/driveMotion" import {useDriveArtifactId} from "@/oss/components/Drives/driveSessionContext" import {humanSize} from "@/oss/components/Drives/driveTree" @@ -23,93 +28,139 @@ import StreamsTab from "@/oss/components/SessionInspector/tabs/StreamsTab" /** The session's files, via the shared drive stack — a click opens the same Quick Look drawer as * the chat/config surfaces; "View all files" opens the full Files drawer. */ -const DriveFilesCard = ({sessionId}: {sessionId: string}) => { - const artifactId = useDriveArtifactId() - const drive = useSessionDriveSummary(sessionId, artifactId ?? undefined) +const DriveFilesCard = ({ + sessionId, + drive, +}: { + sessionId: string + drive: ReturnType +}) => { const openQuickLook = useSetAtom(driveQuickLookAtomFamily(sessionId)) const openFiles = useSetAtom(filesDrawerOpenAtomFamily(sessionId)) - if (drive.errored) - return ( - - Couldn’t load this session’s files. - - ) - if (drive.isLoading) return Loading… - if (drive.fileCount === 0) - return ( - - No files yet — this conversation gets its drive on first run. - - ) - // Files exist but none were written/edited in THIS conversation (recents come from its record - // log) — say so instead of an empty list. - if (drive.recents.length === 0) - return ( - - ) + // The loading skeleton is the SAME list rendering placeholder rows, so skeleton → real is a + // per-row content swap inside one AnimatePresence (no block→list jump, no layout shift). Terminal + // states (error / no-changes / empty) crossfade with the list. + const showSkeleton = drive.isLoading + const rows = drive.recents.slice(0, 5) + // `reconciling` keeps us in the list surface (content + a "Loading more…" hint) while a sibling + // drive is still loading — so the terminal "No files" never flashes before all drives resolve. + const phase = drive.errored + ? "error" + : showSkeleton || rows.length > 0 || drive.reconciling + ? "list" + : drive.fileCount > 0 + ? "no-changes" + : "empty" + return ( -
- - - {drive.recents.slice(0, 5).map((f) => ( - - - f.is_folder ? openFiles(true) : openQuickLook({path: f.path}) - } - /> - - ))} - - - {drive.isFetching ? ( -
- - Loading more… -
- ) : null} - {drive.fileCount > 5 ? ( - - ) : null} -
+ + + {phase === "error" ? ( + + Couldn’t load this session’s files.{" "} + {drive.retry ? ( + + ) : null} + + ) : phase === "no-changes" ? ( + // Files exist but none were written/edited in THIS conversation (recents come + // from its record log) — say so instead of an empty list. + + ) : phase === "empty" ? ( + + No files yet — this conversation gets its drive on first run. + + ) : ( +
+ + + {showSkeleton + ? Array.from({length: SKELETON_ROW_COUNT}, (_, i) => ( + + + + )) + : rows.map((f) => ( + + + f.is_folder + ? openFiles(true) + : openQuickLook({path: f.path}) + } + /> + + ))} + + + {!showSkeleton && (drive.reconciling || drive.isFetching) ? ( +
+ + Loading more… +
+ ) : null} + {drive.fileCount > 5 ? ( + + ) : null} +
+ )} +
+
) } export function RuntimeLens({sessionId}: {sessionId: string}) { + const artifactId = useDriveArtifactId() + const drive = useSessionDriveSummary(sessionId, artifactId ?? undefined) return (
} + // A mount failed but files still loaded → badge the section's OWN folder icon (no new + // row, visible even collapsed); the retry lives in the drawer, reached from the card. + icon={ + + + + } title="Files" size="compact" noDivider > - +
) diff --git a/web/oss/src/components/Drives/ContextRail.tsx b/web/oss/src/components/Drives/ContextRail.tsx index 64d98d5dab..7700bdaab9 100644 --- a/web/oss/src/components/Drives/ContextRail.tsx +++ b/web/oss/src/components/Drives/ContextRail.tsx @@ -13,7 +13,13 @@ import {AnimatePresence, MotionConfig, motion} from "motion/react" import {isSessionFresh} from "@/oss/components/AgentChatSlice/state/sessionEphemera" -import {DriveFileRow, FOCUS_RING} from "./DriveFileRow" +import { + DriveFileRow, + DriveRetryButton, + DriveWarningBadge, + FOCUS_RING, + SKELETON_ROW_COUNT, +} from "./DriveFileRow" import {DriveItemContextMenu, useCopyDrivePath, useDriveItemDownload} from "./DriveItemContextMenu" import {listArrowKeyDown} from "./driveKeyboard" import {FILE_ITEM_VARIANTS, FILE_SPRING} from "./driveMotion" @@ -95,18 +101,32 @@ export function ContextRail({ - {drive.fileCount > 0 ? ( + {drive.fileCount > 0 || drive.partialErrored ? ( + // A mount failure badges the folder (bottom-right, clear of the count pill) and + // swaps the tooltip to the retry hint; the strip already opens on click. - - - - {drive.fileCount} - {drive.fileCountCapped ? "+" : ""} + + + + {drive.fileCount > 0 ? ( + + {drive.fileCount} + {drive.fileCountCapped ? "+" : ""} + + ) : null} - + ) : null} {busy ? ( @@ -159,10 +179,23 @@ const ExpandedRail = ({ ) : null}
- + {/* A mount failure badges the open-drawer folder and swaps its tooltip to the retry + hint (the drawer carries the actual Try again). */} +
- {drive.fileCount === 0 ? ( - - {drive.isLoading ? "Loading…" : "No files yet."} - - ) : drive.recents.length === 0 ? ( - // Files exist but none changed in THIS conversation (recents = its record log). - - No changes yet — open “View all files” to browse. - - ) : ( - <> - {/* The recent files as friendly thumbnail cards (a preview a user recognises - at a glance) — the rail has room for it; older files live behind "View - all files". */} - - - {drive.recents.slice(0, 5).map((file) => { - // Route the thumbnail read to the file's own mount (cwd or - // agent-files); the card still displays the presented path. - const resolved = drive.resolveMount(file.path) - const relTime = file.touchedAt - ? relativeTime(file.touchedAt).replace(" ago", "") - : undefined - return ( - - - file.is_folder - ? onOpenFiles() - : onQuickLook(file.path) - } - onCopyPath={copyPath} - onDownload={download} - className="w-full" - > - - file.is_folder - ? onOpenFiles() - : onQuickLook(file.path) - } - /> - - - ) - })} - - - {drive.isFetching ? ( -
- - Loading more… -
- ) : null} - {drive.fileCount > 5 ? ( - - ) : null} - - )} + {phase === "error" ? ( + + Couldn’t load files.{" "} + {drive.retry ? ( + + ) : null} + + ) : phase === "no-changes" ? ( + // Files exist but none changed in THIS conversation (recents = its + // record log). + + No changes yet — open “View all files” to browse. + + ) : phase === "empty" ? ( + + No files yet. + + ) : ( + <> + {/* The recent files as friendly thumbnail cards (a preview a + user recognises at a glance) — the rail has room; older + files live behind "View all files". */} + + + {showSkeleton + ? Array.from( + {length: SKELETON_ROW_COUNT}, + (_, i) => ( + + + + ), + ) + : rows.map((file) => { + // Route the thumbnail read to the file's own mount (cwd or + // agent-files); the card still displays the presented path. + const resolved = drive.resolveMount( + file.path, + ) + const relTime = file.touchedAt + ? relativeTime( + file.touchedAt, + ).replace(" ago", "") + : undefined + return ( + + + file.is_folder + ? onOpenFiles() + : onQuickLook( + file.path, + ) + } + onCopyPath={copyPath} + onDownload={download} + className="w-full" + > + + file.is_folder + ? onOpenFiles() + : onQuickLook( + file.path, + ) + } + /> + + + ) + })} + + + {!showSkeleton && + (drive.reconciling || drive.isFetching) ? ( +
+ + Loading more… +
+ ) : null} + {drive.fileCount > 5 ? ( + + ) : null} + + )} + + + ) + })()}
) diff --git a/web/oss/src/components/Drives/DriveExplorer.tsx b/web/oss/src/components/Drives/DriveExplorer.tsx index a9501e02d0..108bf3022b 100644 --- a/web/oss/src/components/Drives/DriveExplorer.tsx +++ b/web/oss/src/components/Drives/DriveExplorer.tsx @@ -39,6 +39,7 @@ import { MagnifyingGlass, SidebarSimple, Tray, + WarningCircle, X, } from "@phosphor-icons/react" import {useVirtualizer} from "@tanstack/react-virtual" @@ -61,7 +62,7 @@ import {AnimatePresence, animate, motion, useMotionValue} from "motion/react" import {projectIdAtom} from "@/oss/state/project" import {DriveExplorerSkeleton, TileGridSkeleton} from "./DriveExplorerSkeleton" -import {DriveFileRow, FOCUS_RING} from "./DriveFileRow" +import {DriveFileRow, DriveRetryButton, FOCUS_RING} from "./DriveFileRow" import {driveFileIcon} from "./driveIcons" import { DriveItemContextMenu, @@ -964,6 +965,9 @@ const DriveHeader = ({ downloadingAll, expanded, onToggleExpand, + partialErrored, + onRetry, + retrying, }: { selectedPath: string | null isFolder: boolean @@ -993,6 +997,11 @@ const DriveHeader = ({ * hide the toggle (embedded/non-drawer hosts that don't own the drawer width). */ expanded?: boolean onToggleExpand?: () => void + /** A mount failed but the drive still browses — surface a compact warning + retry INLINE in this + * header (using its existing slack), never a new row. `retrying` drives the spinner. */ + partialErrored?: boolean + onRetry?: () => void + retrying?: boolean }) => { const atRoot = !selectedPath // A file always has details (size/modified); a folder only when it's a repo. Nothing selected @@ -1063,6 +1072,21 @@ const DriveHeader = ({ ) : null} + {/* A mount failed but the drive still browses — a compact warning + retry that lives in + the header's existing slack (never a new row). Tooltip carries the full message so the + inline footprint stays "⚠ Try again". */} + {partialErrored && onRetry ? ( + + + + + + + ) : null}
{selectedPath ? ( @@ -1653,9 +1677,10 @@ export function DriveExplorer({ didInitialFocus.current = true }, [lazyTree.rootLoading, selectedPath, flatRows.length, indexByPath, focusTreeRow]) - // Only a TOTAL failure blanks the drawer. A partial failure — e.g. the artifact-scoped agent - // mount erroring while the session's own files loaded — still has a tree to browse, so fall - // through and render it rather than hiding the loaded files behind the banner. + // Only a TOTAL failure blanks the drawer. A partial failure — the artifact-scoped agent mount + // erroring while the session's own files loaded (or vice-versa) — still has a tree to browse, so + // it falls through and renders the tree; its retry rides the existing header (see DriveHeader's + // `partialErrored` slot), NOT a new banner row that would shove the content down. let body: ReactNode if (drive.errored && drive.fileCount === 0) { body = ( @@ -1667,6 +1692,15 @@ export function DriveExplorer({ description={ The file store may not be configured on this deployment. + {drive.retry ? ( + <> + {" "} + + + ) : null} } /> @@ -1927,6 +1961,9 @@ export function DriveExplorer({ downloadingAll={downloadingAll} expanded={drawerExpanded} onToggleExpand={onToggleExpand} + partialErrored={drive.partialErrored} + onRetry={drive.retry} + retrying={drive.isFetching} /> {/* Shared toolbar — the show/hide tree toggle sits FIRST (left), directly above the tree pane it controls; then search + filters. Search forces the tree of matches diff --git a/web/oss/src/components/Drives/DriveFileRow.tsx b/web/oss/src/components/Drives/DriveFileRow.tsx index d0694dbea2..d7bc6788b1 100644 --- a/web/oss/src/components/Drives/DriveFileRow.tsx +++ b/web/oss/src/components/Drives/DriveFileRow.tsx @@ -15,7 +15,8 @@ import {type CSSProperties, type ReactNode} from "react" import {type Mount} from "@agenta/entities/session" -import {FolderSimple} from "@phosphor-icons/react" +import {ArrowClockwise, CircleNotch, FolderSimple} from "@phosphor-icons/react" +import {Tooltip} from "antd" import {driveFileIcon} from "./driveIcons" import {isHiddenPath} from "./driveTree" @@ -31,15 +32,99 @@ export type DriveFileVariant = "row" | "card" | "tile" export const FOCUS_RING = "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-[var(--ant-color-primary)]" +/** + * DriveRetryButton — the retry affordance for a drive's errored state. Reads inline in the flow of the + * surrounding copy ("Couldn't load files. ↻ Try again"): a retry glyph + link-toned text. + * + * ALIGNMENT: the button is `display:inline` (NOT inline-flex) so its TEXT sits on the copy's baseline + * naturally — `inline-flex align-baseline` synthesises the container baseline as its bottom edge + * (there's no baseline-aligned flex item), which floated the whole thing above the line. `[font:inherit]` + * makes it take the copy's font (preflight is off → a raw ` +) + +/** + * DriveWarningBadge — overlays a small amber dot on the drive-drawer TRIGGER's folder icon when a + * mount failed but the drive still browses (`partialErrored`). It rides the folder glyph that's + * already there (no separate element widening the row), reads as an "attention" notification, and + * since the folder IS the drawer-opener, a tap reaches the drawer where the retry lives. Tooltip + * carries the message. `show=false` → renders the children untouched (zero footprint when healthy). + * `corner` dodges an existing corner badge (the collapsed rail's count pill sits top-right). + */ +export const DriveWarningBadge = ({ + show, + corner = "tr", + tooltip = true, + children, +}: { + show?: boolean + corner?: "tr" | "br" + /** Own tooltip. Set false when the wrapped trigger already has one (swap ITS title instead) so two + * tooltips don't fire on the same target. */ + tooltip?: boolean + children: ReactNode +}): ReactNode => { + // Return the child verbatim when healthy — NOT a fragment, so an outer still gets a + // single ref-able element to wrap. + if (!show) return children + const pos = corner === "br" ? "-bottom-0.5 -right-0.5" : "-right-0.5 -top-0.5" + const badged = ( + + {children} + + + ) + return tooltip ? ( + {badged} + ) : ( + badged + ) +} + // "Just changed" border: a simple, muted accent border (no glow — it read wrong on dense file // rows). List rows also drop their radius (square) so the accent reads as a crisp edge. const RECENT_BORDER = `color-mix(in srgb, ${AGENT_ACCENT} 55%, transparent)` +// How many placeholder rows a loading summary list renders — a list, not a promise of a count. The +// summary surfaces (config Files, chat rail, Runtime Files) all show up to 5 recents but cap the +// skeleton lower so the resolve (skeletons → real rows) never has to shrink far. +export const SKELETON_ROW_COUNT = 3 + +// Shimmer placeholder. `bg-colorFillSecondary` reads as a quiet loading block on every surface tone. +const BAR = "animate-pulse rounded bg-colorFillSecondary" +// Varied name-bar widths so a skeleton list reads as real files, not a barcode. Indexed by row. +const SKELETON_NAME_WIDTHS = ["62%", "44%", "72%"] + export const DriveFileRow = ({ - path, + path = "", trailing, recent, - onOpen, + onOpen = () => {}, variant = "row", file, mount, @@ -47,13 +132,17 @@ export const DriveFileRow = ({ hideFolder, isFolder, staticThumb, + loading, + skeletonIndex = 0, }: { - path: string + /** Required unless `loading`. */ + path?: string /** Right-aligned (row) or secondary-line (card/tile) meta — size / relative time. */ trailing?: ReactNode /** Highlight as just-changed (teal accent). */ recent?: boolean - onOpen: () => void + /** Required unless `loading`. */ + onOpen?: () => void /** Item look; see file header. Defaults to the compact row. */ variant?: DriveFileVariant /** The file + its mount — required by the card/tile thumbnail preview. */ @@ -70,7 +159,81 @@ export const DriveFileRow = ({ /** card/tile: draw the kind icon instead of fetching a content thumbnail — for the always-mounted * summary surfaces, so they don't read every recent file just to preview it. */ staticThumb?: boolean + /** Loading placeholder: same shell (dimensions/padding/border) as a real row of this variant, with + * shimmer bars instead of content — so skeleton→real is a content swap with zero layout shift. + * Non-interactive (aria-hidden, not a button). `path`/`onOpen` are ignored. */ + loading?: boolean + /** Row position, only for the loading placeholder — varies the name-bar width so the list of + * skeletons doesn't read as a barcode. */ + skeletonIndex?: number }) => { + if (loading) { + const nameW = SKELETON_NAME_WIDTHS[skeletonIndex % SKELETON_NAME_WIDTHS.length] + // ROW placeholder — reuses the real row's shell classes (minus button/hover/cursor) so the + // padding, gap, transparent left-accent slot, and the `font-mono text-xs` line box that drives + // the row height all match exactly. Bars sit inside those real containers. + if (variant === "row") { + return ( +
+ + + + + + + +
+ ) + } + // TILE placeholder — the vertical grid tile (thumb on top + centred name + meta). + if (variant === "tile") { + return ( +
+
+ + + + + + +
+ ) + } + // CARD placeholder (horizontal) — thumb + two stacked meta bars. + return ( +
+
+
+
+
+ + + + + + +
+
+ ) + } // Always the basename — folders never bloat the visible name (a nested/long path would // truncate the important tail). The full relative path is on the `title` tooltip instead. const name = path.split("/").pop() ?? path diff --git a/web/oss/src/components/Drives/StorageFilesHeader.tsx b/web/oss/src/components/Drives/StorageFilesHeader.tsx index 23a2e4bdb0..d00b315699 100644 --- a/web/oss/src/components/Drives/StorageFilesHeader.tsx +++ b/web/oss/src/components/Drives/StorageFilesHeader.tsx @@ -11,7 +11,7 @@ import {Skeleton} from "antd" import {useSetAtom} from "jotai" import {configFilesDrawerAtomFamily, useConfigDrive} from "./configDrive" -import {FOCUS_RING} from "./DriveFileRow" +import {DriveWarningBadge, FOCUS_RING} from "./DriveFileRow" export default function StorageFilesHeader({revisionId}: {revisionId?: string | null}) { const {drive} = useConfigDrive(revisionId) @@ -31,6 +31,26 @@ export default function StorageFilesHeader({revisionId}: {revisionId?: string | const label = count === 1 && !drive.fileCountCapped ? "1 file" : `${shown} files` if (count === 0) { + // Zero files but a mount failed (e.g. the agent mount errored over an empty session) → a plain + // "No files" would hide the failure, so badge the folder icon and keep it a button into the + // drawer (where the retry lives). A clean empty just reads "No files". + if (drive.partialErrored) { + return ( + + ) + } return No files } @@ -53,8 +73,11 @@ export default function StorageFilesHeader({revisionId}: {revisionId?: string | ) : null} {label} {/* Opens the Files drawer (a side panel), NOT a new tab — a folder-open glyph, not the - external-link arrow that read as "leaves the page". */} - + external-link arrow that read as "leaves the page". A mount failure badges this folder + (the button already opens the drawer, where the retry lives). */} + + + ) } diff --git a/web/oss/src/components/Drives/StorageSection.tsx b/web/oss/src/components/Drives/StorageSection.tsx index e3f59e8d27..c10b35002e 100644 --- a/web/oss/src/components/Drives/StorageSection.tsx +++ b/web/oss/src/components/Drives/StorageSection.tsx @@ -11,13 +11,13 @@ import {useMemo} from "react" import {CircleNotch} from "@phosphor-icons/react" -import {Skeleton, Typography} from "antd" +import {Typography} from "antd" import {useAtom} from "jotai" import {AnimatePresence, MotionConfig, motion} from "motion/react" import {configFilesDrawerAtomFamily, useConfigDrive} from "./configDrive" import {type DriveId} from "./DriveExplorer" -import {DriveFileRow} from "./DriveFileRow" +import {DriveFileRow, DriveRetryButton, SKELETON_ROW_COUNT} from "./DriveFileRow" import {DriveItemContextMenu, useCopyDrivePath, useDriveItemDownload} from "./DriveItemContextMenu" import {listArrowKeyDown} from "./driveKeyboard" import {FILE_ITEM_VARIANTS, FILE_SPRING} from "./driveMotion" @@ -99,69 +99,130 @@ export default function StorageSection({revisionId}: {revisionId?: string | null const visibleRecents = drive.recents const showOrigin = driveHasMixedOrigins(visibleRecents) + // The loading skeleton is NOT a separate block — it's the same list rendering placeholder rows, + // so the resolve is a per-row content swap (skeleton → real) inside one AnimatePresence, with zero + // layout shift. Terminal states (error / no-session / no-changes / empty) crossfade with the list. + const showSkeleton = drive.isLoading + const rows = visibleRecents.slice(0, 5) + // `reconciling` keeps us in the list surface (content + a "Loading more…" hint) while a sibling + // drive is still loading — so the terminal "No files" never flashes before all drives resolve. + const phase = drive.errored + ? "error" + : showSkeleton || rows.length > 0 || drive.reconciling + ? "list" + : !sessionId + ? "no-session" + : drive.fileCount > 0 + ? "no-changes" + : "empty" + return (
- {drive.isLoading ? ( - - ) : visibleRecents.length > 0 ? ( - // Files win regardless of session status — the agent's durable folder is per-artifact, - // so it shows even before any conversation opens. -
- - - {visibleRecents.slice(0, 5).map((file) => ( - - openDrawer(file.path)} - onCopyPath={copyPath} - onDownload={download} + + + {phase === "list" ? ( + // Files win regardless of session status — the agent's durable folder is + // per-artifact, so it shows even before any conversation opens. +
+ + + {showSkeleton + ? Array.from({length: SKELETON_ROW_COUNT}, (_, i) => ( + + + + )) + : rows.map((file) => ( + + openDrawer(file.path)} + onCopyPath={copyPath} + onDownload={download} + /> + + ))} + + + {/* One mount is in but another is still loading — a quiet hint, NOT a + skeleton that would hide the files already shown. */} + {!showSkeleton && (drive.reconciling || drive.isFetching) ? ( +
+ + Loading more… +
+ ) : null} +
+ ) : phase === "error" ? ( +
+ + Couldn’t load files.{" "} + {drive.retry ? ( + - - ))} - - - {/* One mount is in but another is still loading — a quiet hint, not a skeleton - that would hide the files already shown. */} - {drive.isFetching ? ( -
- - Loading more… + ) : null} + + {/* The diagnostic is now secondary + conditional — a retry may well fix a + transient failure; the "not configured" hint only matters if it keeps + failing (self-hosted deploys without an object store). */} + + If it keeps failing, the file store may not be configured on this + deployment. +
- ) : null} -
- ) : drive.errored ? ( - - Couldn’t load files — the file store may not be configured on this - deployment. - - ) : !sessionId ? ( - - No conversation open yet — the agent’s working files appear here once a - chat starts. - - ) : drive.fileCount > 0 ? ( - // Files exist in the drive, but none were written/edited in THIS conversation (the - // recents come from its record log) — so surface the count, not "no files". - - No changes in this conversation yet — open Files to browse all {drive.fileCount} - {drive.fileCountCapped ? "+" : ""}. - - ) : ( - - No files yet — the agent gets its working folder on the first run. - - )} + ) : phase === "no-session" ? ( + + No conversation open yet — the agent’s working files appear here + once a chat starts. + + ) : phase === "no-changes" ? ( + // Files exist in the drive, but none were written/edited in THIS conversation + // (the recents come from its record log) — surface the count, not "no files". + + No changes in this conversation yet — open Files to browse all{" "} + {drive.fileCount} + {drive.fileCountCapped ? "+" : ""}. + + ) : ( + + No files yet — the agent gets its working folder on the first run. + + )} +
+
{/* The ONE Files drawer (DriveExplorer: lazy per-directory loading + the single header). Same component the chat uses; only the open-atom + resolved drive differ. */} diff --git a/web/oss/src/components/Drives/useSessionDrive.ts b/web/oss/src/components/Drives/useSessionDrive.ts index 7eb6739c98..ba95307a1e 100644 --- a/web/oss/src/components/Drives/useSessionDrive.ts +++ b/web/oss/src/components/Drives/useSessionDrive.ts @@ -1,4 +1,4 @@ -import {useMemo} from "react" +import {useCallback, useMemo} from "react" import { latestMountFilesQueryFamily, @@ -76,14 +76,30 @@ export interface SessionDriveData { /** The most recent touch across the drive, for the `Updated {rel} · {n} files` summary. */ lastTouchedAt: number | null summary: string - /** No data to show YET — a blocking skeleton is appropriate. Goes false as soon as ANY mount's - * data arrives (the other may still be loading — see {@link isFetching}). */ + /** No data to show YET — a blocking skeleton is appropriate. Goes false as soon as the FIRST + * mount answers (files, empty, or error); the other may still be loading — see {@link reconciling} + * / {@link isFetching}. The fast drive is never blocked on the slow one. */ isLoading: boolean + /** Past the initial blank: one drive has answered but a sibling is still catching up. Surfaces + * keep the list (content + a "loading more" hint) instead of the terminal "No files" while data + * is still arriving. Optional (the full drive omits it). */ + reconciling?: boolean /** A listing is still in flight even though some data may already be shown — for a subtle - * "loading more" hint rather than a blocking skeleton. Optional (the full drive omits it). */ + * "loading more" hint rather than a blocking skeleton. Covers in-place revalidation too (a + * session-switch refetch over cached data). Optional (the full drive omits it). */ isFetching?: boolean - /** Listing (or mount discovery) failed — e.g. object store not configured. */ + /** Listing (or mount discovery) failed with nothing to show — e.g. object store not configured. + * Drives the inline error + Retry card. Session-side only in the summary (an agent-only failure + * is {@link partialErrored} instead). */ errored: boolean + /** A mount failed but the drive still shows content (or only the per-artifact agent mount broke) — + * so the inline list stays clean and the failure is surfaced as a warning indicator on the + * drawer-trigger, with the retry handled inside the drawer. Optional (the full drive omits it). */ + partialErrored?: boolean + /** Re-run the failed listing/count queries (for a retry affordance in the {@link errored} state). + * Optional — provided by the summary hook; the full drive omits it. `isFetching` goes true while a + * retry is in flight. */ + retry?: () => void /** Map a presented path (as it appears in `files`/`recents`) to the mount + mount-relative path * that backs it — the cwd mount, or the nested `agent-files/` agent mount — for read/download. */ resolveMount: (path: string) => ResolvedMountPath | null @@ -334,7 +350,20 @@ export function useSessionDriveSummary(sessionId: string, artifactId?: string): // id) whenever the record log already has visible changes — no wasted request in the common case. const rootQuery = useAtomValue(mountRootQueryFamily(hasVisibleRecords ? "" : (mount?.id ?? ""))) - return useMemo(() => { + // Re-run the underlying queries (retry from the errored state). `refetch()` is a no-op on the + // disabled ones (empty id), so this only re-hits what could actually load; `isFetching` reflects + // it in flight, driving the retry button's spinner. + const retry = useCallback(() => { + void mountsQuery.refetch?.() + void cwdCount.refetch?.() + void rootQuery.refetch?.() + if (artifactId) { + void agentMountQuery.refetch?.() + void agentCount.refetch?.() + } + }, [mountsQuery, cwdCount, rootQuery, agentMountQuery, agentCount, artifactId]) + + const data = useMemo(() => { // Newest write/edit per path (the map already dedups by path, keeping the latest timestamp). const recordRecents: DriveRecentFile[] = [...recordRecency.entries()] .map(([toolPath, at]) => ({path: cleanPath(toolPath), touchedAt: at})) @@ -374,18 +403,52 @@ export function useSessionDriveSummary(sessionId: string, artifactId?: string): return mount ? {mount, path: rel} : null } - // "A request is in flight" — keyed on fetchStatus (`.isFetching`), NOT `.isPending`. A session - // switch revalidates the swapped mount's CACHED count (data present, so `isPending` is false) - // yet is actively refetching; without fetchStatus the summary surfaces would show no indicator - // during that. Disabled queries (empty id) are idle, so they never count. + // The two drives — the session cwd and the per-artifact agent mount — resolve INDEPENDENTLY, + // and neither blocks the other: content shows the instant either side returns it, and the + // slower side reconciles in afterward. So the flags below are framed around per-side + // RESOLUTION, not a global "is anything fetching". + // + // A side is "still resolving" = in play (a session / an artifact was given) but it hasn't + // produced its answer yet — neither data nor an error. `isPending` covers first-load mount + // discovery; the `data === undefined` term covers the frame between the mount landing and its + // count query flipping to fetching. A background REVALIDATION (data present, refetching) is NOT + // resolving — it reconciles in place and only feeds the subtle `isFetching` hint below. + const cwdResolving = + Boolean(sessionId) && + (mountsQuery.isPending || + (Boolean(mount) && cwdCount.data === undefined && !cwdCount.isError)) + const agentResolving = + Boolean(artifactId) && + (agentMountQuery.isPending || + (Boolean(agentMount) && agentCount.data === undefined && !agentCount.isError)) + + const cwdInPlay = Boolean(sessionId) + const agentInPlay = Boolean(artifactId) + // Has at least one in-play side produced its answer (files, empty, or error)? + const anyResolved = (cwdInPlay && !cwdResolving) || (agentInPlay && !agentResolving) + const anyResolving = cwdResolving || agentResolving + const hasContent = recents.length > 0 || fileCount > 0 + + // "A request is in flight over data that may already be shown" — the subtle "Loading more…" + // hint. Keyed on fetchStatus so a session-switch revalidation (cached data, refetching) still + // surfaces it. Disabled queries (empty id) are idle and never count. const isFetching = mountsQuery.isFetching || cwdCount.isFetching || (Boolean(artifactId) && (agentMountQuery.isFetching || agentCount.isFetching)) || rootQuery.isFetching - // Block with a skeleton ONLY while there's nothing to show yet (the first load) — once any data - // is in, a refetch shows the subtle "fetching" hint instead of a blocking skeleton. - const isLoading = isFetching && recents.length === 0 && fileCount === 0 + + // BLOCKING skeleton ONLY at the very start — before ANY in-play side has answered and with + // nothing to show. The moment one side answers (or the record log yields recents), we drop the + // skeleton and render what we have; the fast drive is never held hostage to the slow one. + const isLoading = (cwdInPlay || agentInPlay) && !anyResolved && !hasContent + // RECONCILING: past that initial blank, a sibling is still catching up. Surfaces keep the list + // (whatever content is in + a "Loading more…" hint) instead of flashing the terminal "No files" + // while data is still arriving. Distinct from `isFetching` (which also covers in-place + // revalidation and the idle mount→count handoff frame). + const reconciling = anyResolving && (anyResolved || hasContent) + + // Per-mount failure: the session cwd and the artifact-scoped agent mount fail independently. const sessionErrored = Boolean(sessionId) && ((!mountsQuery.isPending && (mountsQuery.data === null || mountsQuery.isError)) || @@ -399,8 +462,14 @@ export function useSessionDriveSummary(sessionId: string, artifactId?: string): (Boolean(agentMount) && !agentCount.isPending && (agentCount.data === null || agentCount.isError))) - // Only a TOTAL failure reads as errored — a partial one still has recents/count to show. - const errored = (sessionErrored || agentErrored) && fileCount === 0 && recents.length === 0 + // TERMINAL error (the inline error + Retry card) means the SESSION (cwd) side failed with + // nothing to show. An agent-only failure — or any failure with content still visible — is NOT + // terminal: it falls through to the files/empty state so a working session is never masked. + const errored = sessionErrored && fileCount === 0 && recents.length === 0 + // PARTIAL failure: a mount failed but we're NOT in the terminal card (there's content, or only + // the agent side broke). The inline list stays clean; instead the drawer-trigger shows a quiet + // warning indicator and the drawer itself offers the retry (see the drawer's partial banner). + const partialErrored = (sessionErrored || agentErrored) && !errored const summary = isLoading ? "…" @@ -422,8 +491,10 @@ export function useSessionDriveSummary(sessionId: string, artifactId?: string): lastTouchedAt, summary, isLoading, + reconciling, isFetching, errored, + partialErrored, resolveMount, } }, [ @@ -453,4 +524,6 @@ export function useSessionDriveSummary(sessionId: string, artifactId?: string): mountsQuery.isFetching, mountsQuery.isError, ]) + + return {...data, retry} } From ef02a7f24ca67c29de65153859cdac2c997b165c Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Tue, 21 Jul 2026 11:58:43 +0200 Subject: [PATCH 2/2] docs(frontend): clarify drive retry refetch guard comment --- web/oss/src/components/Drives/useSessionDrive.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/web/oss/src/components/Drives/useSessionDrive.ts b/web/oss/src/components/Drives/useSessionDrive.ts index ba95307a1e..c35fa42d72 100644 --- a/web/oss/src/components/Drives/useSessionDrive.ts +++ b/web/oss/src/components/Drives/useSessionDrive.ts @@ -350,9 +350,10 @@ export function useSessionDriveSummary(sessionId: string, artifactId?: string): // id) whenever the record log already has visible changes — no wasted request in the common case. const rootQuery = useAtomValue(mountRootQueryFamily(hasVisibleRecords ? "" : (mount?.id ?? ""))) - // Re-run the underlying queries (retry from the errored state). `refetch()` is a no-op on the - // disabled ones (empty id), so this only re-hits what could actually load; `isFetching` reflects - // it in flight, driving the retry button's spinner. + // Re-run the underlying queries (retry from the errored state). `refetch()` bypasses `enabled` + // and DOES invoke the queryFn on the empty-id (disabled) queries, but each queryFn guards its id + // (`if (!mountId) return null`, etc.) and returns without a request — so this only ever re-hits + // what could actually load. `isFetching` reflects it in flight, driving the retry button's spinner. const retry = useCallback(() => { void mountsQuery.refetch?.() void cwdCount.refetch?.()