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,