Skip to content

fix(app): link mentions, folders, and spaced paths - #59937

Open
DhivakarK-git wants to merge 1 commit into
anomalyco:devfrom
DhivakarK-git:file-link-paths
Open

DhivakarK-git wants to merge 1 commit into
anomalyco:devfrom
DhivakarK-git:file-link-paths

Conversation

@DhivakarK-git

@DhivakarK-git DhivakarK-git commented Oct 28, 2026 •

Copy link
Copy Markdown

Issue for this PR

Closes #53502
Closes #55863
Related: #57126, #51516, #50630, #51872

Type of change

  • Bug fix

What does this PR do?

File links from messages had three gaps:

  • An @-mention href keeps the @, so opening it asked for a path literally starting with @ and returned 404. The parser now returns candidates: the literal path first, then the mention reading. Literal first keeps real files and package scopes safe: @types/node/index.d.ts still matches node_modules/@types/..., and @/lib/utils.ts is treated as an alias, not a mention.
  • Directories never passed the existence check because it accepted files only. Files.kind(path) now reports "file", "directory", or undefined from the same parent listing. Directory links linkify, and clicking one reveals it in the OS file manager instead of asking the file reader for a non-file, which returned the Path is not a file 500. Revealing also avoids launching a message-supplied .app bundle; background links stay quiet. The web cannot open a folder in the OS, so it opens the file browser at the folder path instead of doing nothing.
  • Inline-code paths with spaces were rejected before the existence check could see them. They are candidates now and still only linkify once the host confirms them. Shell-like text without a path root or letter-bearing file extension (bun test src/file, cd packages/app, npm 10.2.0) stays code.

Relative existence checks use one files.search(..., { kind: "any" }); results are matched with native separators and folder trailing separators normalized, so assets/, src/components/, and src\components\ all match. A top-level assets/ resolves on the first check, and a suffix folder resolves through the same search when clicked. A search miss no longer lists the parent.

Clicks still read the file first, so ordinary links keep their old cost. Only a failed read asks kind. Non-final mention candidates read silently so they can fall back; the last candidate replays the stored load error, so a deleted file still shows the message without reading the same path twice.

This overlaps with #57126's directory/space ground; that PR is against the older gui-extensions tree and this one uses the current gui-plugins layout.

How did you verify your code works?

  • Candidate tests: @path, @path:line, @/alias, @types, inner @, and the line selection on the mention candidate.
  • Existence tests: @interaction.ts:74 true, @/interaction.ts false, @types/node/index.d.ts true, directories from the index including trailing and native separators, my docs/notes.md, and no parent listing on a search miss.
  • Inline-code classifier: my docs/notes.md and C:\Program Files\app\file.txt are path candidates; bun test src/file and cd packages/app stay code.
  • bun test src/file in packages/gui-plugins: 70 pass.
  • Full packages/gui-plugins suite: 255 pass, 1 skip.
  • packages/session-ui classifier test: 29 pass.
  • typecheck:tsc clean in packages/gui-plugins, packages/app, packages/plugin, and packages/session-ui.

Visual

<svg xmlns="http://www.w3.org/2000/svg" width="240" height="72" viewBox="0 0 240 72" role="img" aria-label="Mention, spaced, and folder links now open correctly.">
  <style>
    text { font: 8px ui-monospace, SFMono-Regular, Menlo, monospace; fill: currentColor }
    .muted { opacity: .55 }
    .note { font-size: 7px; opacity: .5 }
    .frame { fill: none; stroke: currentColor; stroke-opacity: .22 }
    .pulse { animation: pulse 4s infinite }
    @keyframes pulse { 0%, 100% { opacity: .35 } 50% { opacity: .9 } }
  </style>
  <text class="note" x="12" y="12">FILE LINKS</text>
  <rect class="frame" x="12" y="18" width="216" height="13" rx="3"/>
  <text x="18" y="28">@README.md</text>
  <text class="muted" x="128" y="28">→ opens README.md</text>
  <rect class="frame" x="12" y="35" width="216" height="13" rx="3"/>
  <text x="18" y="45">my docs/notes.md</text>
  <text class="muted" x="140" y="45">→ opens (space)</text>
  <rect class="frame" x="12" y="52" width="216" height="13" rx="3"/>
  <text x="18" y="62">@assets</text>
  <text class="muted" x="86" y="62">folder → opens, no 500</text>
  <circle class="pulse" cx="232" cy="11" r="2" fill="currentColor"/>
</svg>

Screenshots / recordings

No packaged desktop capture is available in this environment.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

Added by the opencode bot (AI-generated).

Related: #50630, #51872

Public API: adds the required kind() method to the plugin Files host interface (backwards compatible for callers; implementers must add it)

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The approach looks sound, and it fits the current gui-plugins layout better than #57126, which overlaps on folders and spaced paths. I couldn't verify it in the app: the change is only visible in the desktop/web app, which this sandbox can't run. The file-link tests pass (70 in gui-plugins, 29 for the session-ui classifier), and typecheck:tsc is clean in all four changed packages. Testers should check @README.md, a top-level folder link, a nested folder link and a missing-file link on desktop and web. Before/after screenshots or a short recording from the author would help.

Public API: adds a required kind(path): Promise<"file" | "directory" | undefined> method to the Files host interface in @opencode/plugin (gui/host-apis.ts). exists keeps its file-only meaning. Plugins that call Files are unaffected. Anything that implements Files itself, such as a test double or another host, must add kind.

Two things are worth fixing, both noted inline: kind() with a trailing separator on a top-level folder, and the extra requests when a clicked file is missing.

Related: #50630, #51872

if (!file) return Promise.resolve(false)
if (!file) return Promise.resolve(undefined)

const parent = /[\\/]/.test(file) ? getDirectory(file) : undefined

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kind() doesn't handle a trailing separator. Folder search results come back as assets/, and getDirectory("assets/") returns "/". So files.kind(any) in the click path (renderer.tsx, the findAnyFileLink branch) lists the filesystem root instead of the workspace root, and reports undefined for a top-level folder. The click still recovers, but only after a failed silent read and a second kind(direct) listing. Trimming trailing separators before computing parent (or calling kind with the trimmed path) would make the first check work.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: kind() trims trailing separators before getDirectory, so a top-level assets/ lists the workspace root and resolves on the first check.

return
}

if (index === candidates.length - 1) await openResolvedFile(attempted ?? direct, candidate.selection, false)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every candidate is read silently first, so for the last candidate a missing file now costs a silent read, a kind listing, then this second non-silent read of the same path. A single-candidate link to a deleted file goes from one request to three. Reading the final candidate non-silently the first time, and checking kind only when that fails, would keep the old cost and the existing load error.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: the final silent failure now replays the load error already stored by files.get, so a missing single-candidate link no longer reads the same path a second time. Only when no stored error exists does it fall back to the visible read.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both earlier points are fixed. kind() now trims the trailing separator, so a top-level folder resolves on the first check. The last failed try no longer reads the same file again, so a missing file costs two requests, down from three. I checked the new commit by reading the code: the file-link tests still pass (70) and tsc is clean in gui-plugins and app. It is still not verified in the desktop or web app, which this sandbox can't run. The public API impact is unchanged from the last review: kind() is added to the plugin Files interface.

One small thing about how the error toast is replayed, noted inline.

const error = files.get(failed)?.error

// The silent attempt already stored the load error; replay it instead of reading the same path again.
if (error) ctx.toast.show({ variant: "error", title: ctx.t("common.requestFailed"), description: error })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This replayed toast doesn't match the one setLoadError would have shown. It uses common.requestFailed instead of toast.file.loadFailed.title. It also fires when the file is not found and already open in a tab, a case setLoadError deliberately skips because the tab already shows the not-found state. Matching both would keep the old behaviour for deleted files. For example, the files model could expose a way to report the stored error, or the skip condition could be repeated here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: the replayed toast uses toast.file.loadFailed.title and skips when the stored state is notFound and the path is already open in a tab, matching setLoadError. A server error still shows even when the tab is open.

Try the literal file path before the @-mention reading, add Files.kind so directory links linkify and open, and let inline-code paths with spaces reach the existence check. Folder clicks reveal in the OS file manager (never launch), background links stay quiet, and web opens the file browser.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The replayed load error now matches the files model: it uses the same toast title and stays quiet when the not-found file already has an open tab. Threads 1 and 2 were already fixed by the previous push. I checked the new commit by reading the code: the file-link tests still pass (70) and tsc is clean in gui-plugins. It is still not verified in the desktop or web app, which this sandbox can't run. The public API impact is unchanged from the last review: kind() is added to the plugin Files interface. No new concerns.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

desktop: path links skip directories and paths with spaces desktop: @-mention file links keep the literal "@" in the path and fail (404/500)

1 participant