Skip to content

feat: Clicking a relative file path in agent output should open it in the changes panel #1875

Description

@open-inspect

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 SessionTimelinerenderFlatItemEventItem → 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 onClickpreventDefault() + 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

  • Clicking a relative link matching a changed file selects that file in the changes panel, with
    no navigation and no new tab
  • Renamed files resolve via oldPath as well as path
  • /workspace/<repoName>/<path> absolute sandbox paths resolve
  • Relative links with no matching changed file render as inert text, not as broken links
  • Absolute URLs, anchors, and mailto: links behave exactly as they do today
  • Multi-repo path collisions resolve deterministically, with a test covering the chosen rule
  • SafeMarkdown outside a session (e.g. create-pull-request-event.tsx) is unaffected
  • No changes to the rehype-sanitize schema
  • Unit tests for the resolver and rendering tests for SafeMarkdown

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions