Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 39 additions & 11 deletions web/oss/src/components/AgentChatSlice/assets/markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,9 +167,46 @@ const CodeBlock = ({
/** Unwrap the markdown `<pre>` — 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) => (
<a href={href} title={title} className={className} target="_blank" rel="noopener noreferrer">
{children}
</a>
)

/** 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 = <ExternalLink href={href} {...rest} />
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) ? <ExternalLink {...props} /> : <DriveLink {...props} />

/** 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
Expand All @@ -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) => (
<a href={href} title={title} className={className} target="_blank" rel="noopener noreferrer">
{children}
</a>
)

const Markdown = ({content, className}: {content: string; className?: string}) => (
<XMarkdown
className={className ? `${MD_CLASS} ${className}` : MD_CLASS}
content={content}
config={LATEX_CONFIG}
dompurifyConfig={DOMPURIFY_CONFIG}
components={{...MD_COMPONENTS, a: Anchor}}
components={MD_COMPONENTS}
/>
)

Expand Down
69 changes: 51 additions & 18 deletions web/oss/src/components/Drives/useSessionDrive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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],
)

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading