diff --git a/web/oss/src/components/AgentChatSlice/assets/markdown.tsx b/web/oss/src/components/AgentChatSlice/assets/markdown.tsx index c07639a951..37059566e3 100644 --- a/web/oss/src/components/AgentChatSlice/assets/markdown.tsx +++ b/web/oss/src/components/AgentChatSlice/assets/markdown.tsx @@ -167,9 +167,46 @@ const CodeBlock = ({ /** Unwrap the markdown `
` — the highlighted block owns its own container. */
 const PreUnwrap = ({children}: {children?: ReactNode}) => <>{children}
 
+/** A link target that must stay a plain external link: any `scheme:` URL (http, https, mailto, tel,
+ * data, …), a protocol-relative `//host`, or an in-page `#fragment`. Everything else is a RELATIVE
+ * path, which might name a file in this conversation's drive. */
+const isExternalHref = (href?: string): boolean =>
+    !href || /^([a-z][a-z0-9+.-]*:|\/\/|#)/i.test(href)
+
+/** Only real anchor attributes — XMarkdown/html-react-parser also pass internal props (`domNode`,
+ * `node`, `streamStatus`, …) that would leak onto the DOM element, so we never spread. */
+interface AnchorProps {
+    href?: string
+    title?: string
+    className?: string
+    children?: ReactNode
+}
+
+/** Plain link, opened in a new tab. Also the fallback whenever a relative href isn't a drive file. */
+const ExternalLink = ({href, title, className, children}: AnchorProps) => (
+    
+        {children}
+    
+)
+
+/** A relative href may NAME a drive file — resolve it through the same resolver the inline-code path
+ * uses ({@link InlineCode}), so a markdown link and a code-span mention of the same file behave
+ * identically (issue #5481: nested / `NN-name/` paths get emitted as links and bypassed it). */
+const DriveLink = ({href, ...rest}: AnchorProps) => {
+    const sessionId = useDriveSessionId()
+    const link = useAtomValue(chatFileLinkAtomFamily(sessionId ?? ""))
+    const fallback = 
+    if (link && href) return <>{link.renderCode(href, fallback)}
+    return fallback
+}
+
+/** Split so an ordinary URL costs nothing: only a relative href subscribes to the drive resolver. */
+const Anchor = (props: AnchorProps) =>
+    isExternalHref(props.href) ?  : 
+
 /** Stable `components` map: a fresh object literal per render churns XMarkdown's prop identity, and
  * this renderer re-renders on every throttled streaming token — so hoist it to a module constant. */
-const MD_COMPONENTS = {code: CodeBlock, pre: PreUnwrap}
+const MD_COMPONENTS = {code: CodeBlock, pre: PreUnwrap, a: Anchor}
 
 /** Shared markdown renderer for the slice — used by message bubbles and the composer live
  * preview, so both render identically. `className` appends to `MD_CLASS` so callers can tweak
@@ -179,22 +216,13 @@ const MD_COMPONENTS = {code: CodeBlock, pre: PreUnwrap}
  * (the streaming one), its already-settled parts — a reasoning block, text before a tool call —
  * keep the same `content` string, so this skips re-parsing + re-running Prism on them each token.
  * (Settled messages don't re-render at all; the stable-`onRewind` fix handles those.) */
-// Anchor component ensures all markdown-rendered links open in a new tab safely.
-// Only forward real anchor attributes — XMarkdown/html-react-parser also pass internal
-// props (`domNode`, `node`, `streamStatus`, …) that would leak onto the DOM element.
-const Anchor = ({href, children, title, className}: any) => (
-    
-        {children}
-    
-)
-
 const Markdown = ({content, className}: {content: string; className?: string}) => (
     
 )
 
diff --git a/web/oss/src/components/Drives/useSessionDrive.ts b/web/oss/src/components/Drives/useSessionDrive.ts
index c35fa42d72..3997fcb0d6 100644
--- a/web/oss/src/components/Drives/useSessionDrive.ts
+++ b/web/oss/src/components/Drives/useSessionDrive.ts
@@ -37,6 +37,20 @@ export const fileOrigin = (path: string): FileOrigin => {
     return rel === AGENT_FILES_DIR || rel.startsWith(`${AGENT_FILES_DIR}/`) ? "agent" : "session"
 }
 
+/**
+ * Does this path belong in a user-facing drive list? ONE question, asked by every list a drive
+ * surface builds AND by the gate that decides whether a list is empty — so a gate can never keep a
+ * row a list then drops (the bug class behind #5480).
+ *
+ * Excluded: runner plumbing ({@link isInternalDrivePath}), and the bare `agent-files` entry — that
+ * one is the fold-point SYMLINK into the agent mount, not a file. Its CONTENTS are listed, folded
+ * under `agent-files/` from the agent mount itself; the marker never is.
+ */
+export const isListableDrivePath = (path: string): boolean => {
+    const rel = cleanPath(path)
+    return Boolean(rel) && rel !== AGENT_FILES_DIR && !isInternalDrivePath(rel)
+}
+
 /** True when a listing holds BOTH agent and session files — the only time the origin tags/filter
  * carry information (a single-origin drive doesn't need them). */
 export const driveHasMixedOrigins = (files: {path: string}[]): boolean => {
@@ -147,7 +161,7 @@ export function useSessionDrive(
     const structural = useMemo(() => {
         const listing = filesQuery.data ?? null
         const cwdStats = driveFileStats(listing)
-        const cwdFiles = cwdStats.files.filter((f) => cleanPath(f.path) !== AGENT_FILES_DIR)
+        const cwdFiles = cwdStats.files.filter((f) => isListableDrivePath(f.path))
 
         // Agent-mount files, presented under `agent-files/` so they read as a subfolder of cwd.
         const agentListing = agentFilesQuery.data ?? null
@@ -327,16 +341,13 @@ export function useSessionDriveSummary(sessionId: string, artifactId?: string):
     // Recents: the agent's own write/edit events from the durable record log (0 object-store scan).
     const recordRecency = useAtomValue(sessionRecordFileRecencyAtomFamily(sessionId))
 
-    // Does the record log hold ANY visible (non-internal) change? When it doesn't, the "recent
-    // changes" list would be empty even though the drive has files — so fall back to the top-level
-    // listing below. Computed here (cheap — records are few) to GATE that query off when records
-    // already carry the list, so an active conversation pays nothing extra.
+    // Does the record log hold ANY change this surface would actually LIST? When it doesn't, the
+    // "recent changes" list would be empty even though the drive has files — so fall back to the
+    // top-level listing below. Same predicate as that list uses, so the gate can't withhold the
+    // fallback over a row the list then drops. Computed here (cheap — records are few) to GATE the
+    // queries off when records already carry the list, so an active conversation pays nothing extra.
     const hasVisibleRecords = useMemo(
-        () =>
-            [...recordRecency.keys()].some((toolPath) => {
-                const p = cleanPath(toolPath)
-                return Boolean(p) && !isInternalDrivePath(p)
-            }),
+        () => [...recordRecency.keys()].some(isListableDrivePath),
         [recordRecency],
     )
 
@@ -345,10 +356,17 @@ export function useSessionDriveSummary(sessionId: string, artifactId?: string):
     const agentCount = useAtomValue(
         latestMountFilesQueryFamily({mountId: agentMount?.id ?? "", limit: 0}),
     )
-    // Fallback list (depth=1, one delimiter call): the drive's TOP-LEVEL entries, so a conversation
-    // that changed nothing still shows what's in the drive instead of an empty list. Disabled (empty
-    // id) whenever the record log already has visible changes — no wasted request in the common case.
+    // Fallback list (depth=1, one delimiter call per mount): the drive's TOP-LEVEL entries, so a
+    // conversation that changed nothing still shows what's in the drive instead of an empty list.
+    // BOTH mounts, mirroring the full drive: the cwd root alone would show the `agent-files` symlink
+    // and nothing behind it, so dropping that marker (see {@link isListableDrivePath}) has to come
+    // with listing the agent mount it stands for — otherwise the fold's files vanish from the summary
+    // entirely (#5480). Disabled (empty id) whenever the record log already has listable changes —
+    // no wasted request in the common case.
     const rootQuery = useAtomValue(mountRootQueryFamily(hasVisibleRecords ? "" : (mount?.id ?? "")))
+    const agentRootQuery = useAtomValue(
+        mountRootQueryFamily(hasVisibleRecords ? "" : (agentMount?.id ?? "")),
+    )
 
     // 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
@@ -361,14 +379,15 @@ export function useSessionDriveSummary(sessionId: string, artifactId?: string):
         if (artifactId) {
             void agentMountQuery.refetch?.()
             void agentCount.refetch?.()
+            void agentRootQuery.refetch?.()
         }
-    }, [mountsQuery, cwdCount, rootQuery, agentMountQuery, agentCount, artifactId])
+    }, [mountsQuery, cwdCount, rootQuery, agentMountQuery, agentCount, agentRootQuery, 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}))
-            .filter((f) => f.path && !isInternalDrivePath(f.path))
+            .filter((f) => isListableDrivePath(f.path))
             .sort((a, b) =>
                 b.touchedAt !== a.touchedAt
                     ? b.touchedAt - a.touchedAt
@@ -377,8 +396,17 @@ export function useSessionDriveSummary(sessionId: string, artifactId?: string):
             .slice(0, SUMMARY_LATEST_LIMIT)
         // No in-conversation changes → present the top-level entries (files carry the store mtime;
         // folders sort after, alphabetically) so the surface reflects the drive's real contents.
-        const rootRecents: DriveRecentFile[] = (rootQuery.data ?? [])
-            .filter((f) => !isInternalDrivePath(f.path))
+        // The agent mount's entries are presented under `agent-files/`, exactly as the full drive
+        // folds them — `resolveMount` below already maps that prefix back, so the rows open.
+        const rootEntries: MountFile[] = [
+            ...(rootQuery.data ?? []),
+            ...(agentRootQuery.data ?? []).map((f) => ({
+                ...f,
+                path: `${AGENT_FILES_DIR}/${cleanPath(f.path)}`,
+            })),
+        ]
+        const rootRecents: DriveRecentFile[] = rootEntries
+            .filter((f) => isListableDrivePath(f.path))
             .map((f) => ({...f, touchedAt: typeof f.mtime === "number" ? f.mtime : undefined}))
             .sort((a, b) =>
                 (b.touchedAt ?? 0) !== (a.touchedAt ?? 0)
@@ -436,7 +464,10 @@ export function useSessionDriveSummary(sessionId: string, artifactId?: string):
         const isFetching =
             mountsQuery.isFetching ||
             cwdCount.isFetching ||
-            (Boolean(artifactId) && (agentMountQuery.isFetching || agentCount.isFetching)) ||
+            (Boolean(artifactId) &&
+                (agentMountQuery.isFetching ||
+                    agentCount.isFetching ||
+                    agentRootQuery.isFetching)) ||
             rootQuery.isFetching
 
         // BLOCKING skeleton ONLY at the very start — before ANY in-play side has answered and with
@@ -508,6 +539,8 @@ export function useSessionDriveSummary(sessionId: string, artifactId?: string):
         rootQuery.data,
         rootQuery.isPending,
         rootQuery.isFetching,
+        agentRootQuery.data,
+        agentRootQuery.isFetching,
         cwdCount.data,
         cwdCount.isPending,
         cwdCount.isFetching,