Summary
When an agent references a repository file in its output, the markdown renderer turns it into an
ordinary link:
<a href="docs/plans/daytona-prebuild-parity.md" target="_blank" rel="noopener noreferrer nofollow"
class="text-accent hover:underline">daytona-prebuild-parity.md</a>
The href is repo-relative, so the browser resolves it against the session URL and the user lands on
/session/docs/plans/daytona-prebuild-parity.md, which 404s.
That file is almost always sitting right there in the Changes sidebar. Clicking the link should
select it in the changes panel instead of navigating away.
Current behavior
SafeMarkdown renders every link identically — raw href plus target="_blank"
(packages/web/src/components/safe-markdown.tsx:74-84). Relative hrefs survive sanitization, because
the schema's protocols.href allowlist only constrains links that carry an explicit scheme
(packages/web/src/components/safe-markdown.tsx:52-54). The markdown layer has no knowledge of the
session or its diff manifest, so a repo-relative path renders as a dead external link.
Desired behavior
- Clicking a relative link in agent output that matches a file in the session's diff manifest selects
that file in the changes panel — same result as clicking the row in Files changed.
- Such a link no longer opens a new tab and no longer navigates.
- A relative link that does not match a changed file renders as inert text rather than a link
(see Scope below). It is styled so it doesn't look clickable, and ideally carries a title
explaining why (e.g. "Not in this session's changes").
- Absolute URLs (
https://…), anchors (#section), and mailto: links keep their current behavior
exactly.
Notes / implementation considerations
The selection API already exists; most of this work is getting a callback to the markdown renderer
and mapping an href string onto a manifest entry.
What's already there
selectedDiff: DiffSelection | null — { repositoryPosition, path }
(packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx:193, type at
packages/web/src/lib/session-diffs.ts:12)
openDiff(repository, file) — sets the selection, closes the mobile details sheet, and records
focus-return state (packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx:286-291)
resolveDiffSelection(manifest, selection) — resolves a {position, path} pair against the
manifest (packages/web/src/lib/session-diffs.ts:78)
SessionChangesPanel then fetches the patch by file id
(packages/web/src/components/session-changes-panel.tsx:254-256)
1. Plumbing — prefer context over prop threading
SafeMarkdown renders at packages/web/src/components/session-timeline.tsx:555, inside a memoized
EventItem (packages/web/src/components/session-timeline.tsx:669), inside a virtualizer. Threading
a callback prop would touch SessionTimeline → renderFlatItem → EventItem → the eventRenderers
map → SafeMarkdown, and would break the memo on EventItem unless the callback is perfectly
stable.
A small context provider (e.g. packages/web/src/lib/session-file-links.tsx) exposing
{ resolve(href), open(selection) } is cleaner. Mount it in the session page, which already holds
both diffState and openDiff. SafeMarkdown reads it with a useContext that returns null when
there's no provider, so the other call site
(packages/web/src/components/create-pull-request-event.tsx) is untouched.
Memoize the context value on diffState.current.revisionId rather than on manifest object identity —
otherwise every SWR revalidation re-renders every visible markdown row in the timeline.
2. Path resolution — the actual design work
Worth its own pure, unit-tested module (e.g. packages/web/src/lib/diff-file-links.ts):
- Skip non-candidates: absolute URLs with a scheme, protocol-relative
//, bare anchors #…,
mailto:.
- Normalize: strip a leading
./, strip query and hash, decode percent-escapes.
- Absolute sandbox paths:
/workspace/<repoName>/<rest> maps cleanly, because checkouts live at
/workspace/{repoName} and repoName is unique per session — enforced at
packages/shared/src/types/repositories.ts:137-153.
- Match against the manifest on exact
file.path per repository, and also on file.oldPath so
renamed files resolve.
- Multi-repo sessions can produce the same path in two repositories. Prefer the lowest
position
(the primary), or require a <repoName>/ prefix to disambiguate — either is acceptable, just make
the choice deliberate and cover it with a test.
- Compare paths case-sensitively (git paths are), but compare
repoName case-insensitively, matching
prArtifactBelongsToRepo in packages/shared/src/types/repositories.ts.
- Optional follow-up, not required here: a unique path-boundary suffix match, for when the
agent writes docs/plans/x.md but the manifest path is packages/web/docs/plans/x.md.
buildUniquePathLabels (packages/web/src/lib/session-diffs.ts:107) is the same idea in reverse.
3. Rendering in SafeMarkdown
- Resolved link: drop
target="_blank", and onClick → preventDefault() + open the selection.
- Route clicks through
openDiff, not setSelectedDiff directly — it also closes the mobile details
sheet and records diffReturnFocusRef
(packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx:286-312).
- No sanitizer changes are needed: the implementation only reads
href, which is already allowlisted
(packages/web/src/components/safe-markdown.tsx:46). Please avoid the alternative of a rehype
plugin stamping data-* attributes onto nodes — that would force widening the sanitize schema.
4. Focus and mobile
- Focus-return on panel close looks for
button[data-diff-path] in the sidebar, but the sidebar is
hidden while the panel is open
(packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx:458). It already falls back to
focusDetailsTrigger(), so nothing is broken — but returning focus to the originating link would
be nicer.
- Mobile needs no special handling: the panel opens as a full-screen
Sheet
(packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx:541).
Scope
Files that aren't in the diff render as inert text. The control plane exposes only the diff
manifest and per-file patches (packages/control-plane/src/routes/session-diffs.ts) — there is no
read-file API into the sandbox, so a link to a file the agent merely read has nowhere to go.
Rendering it as text is still strictly better than today's 404. Adding a sandbox file-read endpoint
and a plain-file viewer mode is a much larger change and is not part of this issue.
Falling back to a forge blob URL (https://github.com/owner/name/blob/...) is also out of scope and
would be wrong as-is: sessionRepositoryStateSchema
(packages/shared/src/types/repositories.ts:29-38) carries owner, name, and branch but no SCM
provider or host, so it would silently mislink every GitLab session.
Line anchors are out of scope. Handling file.ts:42 or #L42 by scrolling the diff to that line
is a separate change — PierreDiffRenderer (packages/web/src/components/pierre-diff-renderer.tsx)
exposes no line-jump API today. Stripping a trailing line reference during normalization so the file
itself still resolves is welcome, but jumping to the line is not required.
Deep linking is a possible follow-up, not required here. selectedDiff is local state with no URL
sync. Encoding the selection in a query param would give shareable links and make cmd-click work
naturally, but it can land separately.
Files
packages/web/src/components/safe-markdown.tsx — the a renderer
packages/web/src/lib/diff-file-links.ts (new) — href → DiffSelection resolution
packages/web/src/lib/session-file-links.tsx (new) — context provider
packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx — mount the provider, wire openDiff
packages/web/src/lib/diff-file-links.test.ts (new) — resolver unit tests
packages/web/src/components/safe-markdown.test.tsx (new) — rendering behavior
Acceptance criteria
Summary
When an agent references a repository file in its output, the markdown renderer turns it into an
ordinary link:
The href is repo-relative, so the browser resolves it against the session URL and the user lands on
/session/docs/plans/daytona-prebuild-parity.md, which 404s.That file is almost always sitting right there in the Changes sidebar. Clicking the link should
select it in the changes panel instead of navigating away.
Current behavior
SafeMarkdownrenders every link identically — rawhrefplustarget="_blank"(
packages/web/src/components/safe-markdown.tsx:74-84). Relative hrefs survive sanitization, becausethe schema's
protocols.hrefallowlist only constrains links that carry an explicit scheme(
packages/web/src/components/safe-markdown.tsx:52-54). The markdown layer has no knowledge of thesession or its diff manifest, so a repo-relative path renders as a dead external link.
Desired behavior
that file in the changes panel — same result as clicking the row in Files changed.
(see Scope below). It is styled so it doesn't look clickable, and ideally carries a
titleexplaining why (e.g. "Not in this session's changes").
https://…), anchors (#section), andmailto:links keep their current behaviorexactly.
Notes / implementation considerations
The selection API already exists; most of this work is getting a callback to the markdown renderer
and mapping an href string onto a manifest entry.
What's already there
selectedDiff: DiffSelection | null—{ repositoryPosition, path }(
packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx:193, type atpackages/web/src/lib/session-diffs.ts:12)openDiff(repository, file)— sets the selection, closes the mobile details sheet, and recordsfocus-return state (
packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx:286-291)resolveDiffSelection(manifest, selection)— resolves a{position, path}pair against themanifest (
packages/web/src/lib/session-diffs.ts:78)SessionChangesPanelthen fetches the patch by file id(
packages/web/src/components/session-changes-panel.tsx:254-256)1. Plumbing — prefer context over prop threading
SafeMarkdownrenders atpackages/web/src/components/session-timeline.tsx:555, inside a memoizedEventItem(packages/web/src/components/session-timeline.tsx:669), inside a virtualizer. Threadinga callback prop would touch
SessionTimeline→renderFlatItem→EventItem→ theeventRenderersmap →
SafeMarkdown, and would break thememoonEventItemunless the callback is perfectlystable.
A small context provider (e.g.
packages/web/src/lib/session-file-links.tsx) exposing{ resolve(href), open(selection) }is cleaner. Mount it in the session page, which already holdsboth
diffStateandopenDiff.SafeMarkdownreads it with auseContextthat returnsnullwhenthere's no provider, so the other call site
(
packages/web/src/components/create-pull-request-event.tsx) is untouched.Memoize the context value on
diffState.current.revisionIdrather than on manifest object identity —otherwise every SWR revalidation re-renders every visible markdown row in the timeline.
2. Path resolution — the actual design work
Worth its own pure, unit-tested module (e.g.
packages/web/src/lib/diff-file-links.ts)://, bare anchors#…,mailto:../, strip query and hash, decode percent-escapes./workspace/<repoName>/<rest>maps cleanly, because checkouts live at/workspace/{repoName}andrepoNameis unique per session — enforced atpackages/shared/src/types/repositories.ts:137-153.file.pathper repository, and also onfile.oldPathsorenamed files resolve.
position(the primary), or require a
<repoName>/prefix to disambiguate — either is acceptable, just makethe choice deliberate and cover it with a test.
repoNamecase-insensitively, matchingprArtifactBelongsToRepoinpackages/shared/src/types/repositories.ts.agent writes
docs/plans/x.mdbut the manifest path ispackages/web/docs/plans/x.md.buildUniquePathLabels(packages/web/src/lib/session-diffs.ts:107) is the same idea in reverse.3. Rendering in
SafeMarkdowntarget="_blank", andonClick→preventDefault()+ open the selection.openDiff, notsetSelectedDiffdirectly — it also closes the mobile detailssheet and records
diffReturnFocusRef(
packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx:286-312).href, which is already allowlisted(
packages/web/src/components/safe-markdown.tsx:46). Please avoid the alternative of a rehypeplugin stamping
data-*attributes onto nodes — that would force widening the sanitize schema.4. Focus and mobile
button[data-diff-path]in the sidebar, but the sidebar ishidden while the panel is open
(
packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx:458). It already falls back tofocusDetailsTrigger(), so nothing is broken — but returning focus to the originating link wouldbe nicer.
Sheet(
packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx:541).Scope
Files that aren't in the diff render as inert text. The control plane exposes only the diff
manifest and per-file patches (
packages/control-plane/src/routes/session-diffs.ts) — there is noread-file API into the sandbox, so a link to a file the agent merely read has nowhere to go.
Rendering it as text is still strictly better than today's 404. Adding a sandbox file-read endpoint
and a plain-file viewer mode is a much larger change and is not part of this issue.
Falling back to a forge blob URL (
https://github.com/owner/name/blob/...) is also out of scope andwould be wrong as-is:
sessionRepositoryStateSchema(
packages/shared/src/types/repositories.ts:29-38) carries owner, name, and branch but no SCMprovider or host, so it would silently mislink every GitLab session.
Line anchors are out of scope. Handling
file.ts:42or#L42by scrolling the diff to that lineis a separate change —
PierreDiffRenderer(packages/web/src/components/pierre-diff-renderer.tsx)exposes no line-jump API today. Stripping a trailing line reference during normalization so the file
itself still resolves is welcome, but jumping to the line is not required.
Deep linking is a possible follow-up, not required here.
selectedDiffis local state with no URLsync. Encoding the selection in a query param would give shareable links and make cmd-click work
naturally, but it can land separately.
Files
packages/web/src/components/safe-markdown.tsx— thearendererpackages/web/src/lib/diff-file-links.ts(new) — href →DiffSelectionresolutionpackages/web/src/lib/session-file-links.tsx(new) — context providerpackages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx— mount the provider, wireopenDiffpackages/web/src/lib/diff-file-links.test.ts(new) — resolver unit testspackages/web/src/components/safe-markdown.test.tsx(new) — rendering behaviorAcceptance criteria
no navigation and no new tab
oldPathas well aspath/workspace/<repoName>/<path>absolute sandbox paths resolvemailto:links behave exactly as they do todaySafeMarkdownoutside a session (e.g.create-pull-request-event.tsx) is unaffectedSafeMarkdown