Skip to content

[Feature]: Make workspace file links in Chat clickable and navigate to file tree / editor. #47

@hongyi-zhao

Description

@hongyi-zhao

Problem

In the Studio Chat panel, AI-generated markdown messages frequently reference workspace files, e.g.:

The assembly script is at [assemble_full_fig2.py](assemble_full_fig2.py), and the delivery notes
are in [FIG2_FULL_NINE_PANEL_DELIVERY.md](FIG2_FULL_NINE_PANEL_DELIVERY.md).

Currently, renderer.link renders all [text](href) uniformly as <a href="..." target="_blank">. Clicking these links causes the browser to open a new tab with a URL like:

http://127.0.0.1:20999/home/werner/DeepScientist/quests/001/.ds/worktrees/.../assemble_full_fig2.py

The web server has no matching route, so the request 404s. The links are effectively dead.

Code trail

Location Role
src/ui/src/lib/plugins/ai-manus/lib/markdown.tsrenderer.link Renders all markdown links as <a href="..." target="_blank">
src/ui/src/lib/plugins/ai-manus/AiManusChatView.tsx L1468, L8343–8377 Already has useOpenFile()findNodeByPath + openFileInTab for file navigation
src/ui/src/components/workspace/QuestWorkspaceSurface.tsx L4650–4679 Same — complete file-tree-click-to-open implementation

Key observation: the file navigation infrastructure already exists (findNodeByPath + openFileInTab); the markdown rendering layer simply does not hook into it.

Proposed implementation

Layer 1 — Link classification

Introduce a utility function that categorizes an href into one of three types:

Type Condition Behavior
Workspace file Relative path, or absolute path rooted under the quest worktree findNodeByPath + openFileInTab
Internal page Same-origin URL SPA navigation
External link Any other URL (http/https) Keep current behavior — target="_blank"
function classifyHref(href: string, questRoot: string):
  | { type: 'workspace-file'; relativePath: string }
  | { type: 'internal'; url: string }
  | { type: 'external'; url: string }

Layer 2 — Click interception

Since renderer.link returns a static HTML string (not a React component), direct React event binding is not possible. Two approaches:

Approach A — Event delegation (recommended, minimal diff)

Add a delegated onClick handler on the Chat message container:

const handleLinkClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
  const anchor = (e.target as HTMLElement).closest('a[href]')
  if (!anchor) return
  const href = anchor.getAttribute('href') ?? ''
  const classified = classifyHref(href, questRoot)
  if (classified.type === 'workspace-file') {
    e.preventDefault()
    const node = findNodeByPath(classified.relativePath)
    if (node) openFileInTab(node)
  }
  // external and internal links fall through to default behavior
}, [questRoot, findNodeByPath, openFileInTab])

Approach B — Custom React component for <a> tags

Switch markdown rendering from dangerouslySetInnerHTML to react-markdown's component mapping, supplying a custom React component for a. Larger diff but better type safety.

Layer 3 — Mark workspace links at render time (optional enhancement)

Add data-* attributes in renderer.link for workspace file hrefs, so the event delegate can classify links without re-parsing:

renderer.link = ({ href, title, text }: Tokens.Link) => {
  const safeHref = href ?? ''
  const titleAttr = title ? ` title="${title}"` : ''
  const classified = classifyHref(safeHref, questRoot)
  if (classified.type === 'workspace-file') {
    return `<a href="${safeHref}" data-ws-path="${escapeHtml(classified.relativePath)}" class="ds-file-link"${titleAttr}>${text}</a>`
  }
  return `<a href="${safeHref}" target="_blank" rel="noopener noreferrer"${titleAttr}>${text}</a>`
}

This also removes target="_blank" from workspace links, preventing an empty tab from opening on click.

Layer 4 — Backend file serving route (optional enhancement)

Register a file serving endpoint (e.g. GET /api/v1/quests/:questId/files/*) so that workspace file links also work when right-click / middle-click opened in a new tab (preview or download). Lower priority than the frontend interception.

Concrete minimal implementation (~15 lines, 2 files)

The existing code already provides all the building blocks. The assistant message container at ChatMessage.tsx L784 already has an onClick={handleAssistantClick} handler that dispatches to handleMarkdownAction and handleCitationClick. The onFileClick prop pattern is already established. The fix threads a new onFileLinkClick callback through the same path.

File 1: src/ui/src/lib/plugins/ai-manus/components/ChatMessage.tsx

1a. Add prop (L52, inside ChatMessageProps):

  onFileClick?: (fileId: string) => void
  onFileLinkClick?: (path: string) => void       // ← new

1b. Destructure it (around L267 where other props are destructured):

  onFileLinkClick,

1c. Extend handleAssistantClick (L623–629) — add file link interception before existing handlers:

  const handleAssistantClick = useCallback(
    (event: React.MouseEvent<HTMLElement>) => {
      // --- file link interception (new) ---
      const anchor = (event.target as HTMLElement).closest('a[href]')
      if (anchor) {
        const href = anchor.getAttribute('href') ?? ''
        if (href && !href.startsWith('http') && !href.startsWith('mailto:')) {
          event.preventDefault()
          onFileLinkClick?.(href)
          return
        }
      }
      // --- existing handlers ---
      if (handleMarkdownAction(event)) return
      handleCitationClick(event)
    },
    [handleCitationClick, handleMarkdownAction, onFileLinkClick]
  )

File 2: src/ui/src/lib/plugins/ai-manus/AiManusChatView.tsx

2a. Pass the callback (L8709, where <ChatMessage> is rendered):

  onFileLinkClick={async (path) => {
    const node = findNodeByPath(path)
    if (node) {
      await openFileInTab(node, { customData: projectId ? { projectId } : undefined })
    }
  }}

findNodeByPath and openFileInTab are already in scope (L1468–1470).

Why this works

  • handleAssistantClick already fires on every click inside the assistant message div (L784).
  • closest('a[href]') catches clicks on the link text, nested <code> spans, etc.
  • Non-URL hrefs (relative paths, absolute filesystem paths) are the only ones that don't start with http or mailto:, which is exactly the set of workspace file references.
  • External links (http://...) fall through to the existing target="_blank" behavior unchanged.

Suggested priority

  1. P0 — The minimal implementation above (~15 lines across 2 files): intercept non-URL hrefs in handleAssistantClick, route to findNodeByPath + openFileInTab
  2. P1 — Add data-ws-path marker and remove target="_blank" in renderer.link for workspace paths (Layer 3 above) to avoid the brief blank-tab flash
  3. P1 — Register a backend file serving route for new-tab / direct-URL access (Layer 4 above)
  4. P2 — Visual differentiation: style workspace file links differently from external links (e.g. file icon prefix)

Environment

  • DeepScientist v1.5.17
  • Ubuntu, Chrome / Firefox

Proposed solution

See above.

User-facing impact

See above.

Alternatives considered

No response

Expected scope

No response

Compatibility or migration concerns

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions