From 4cd371b55de1b922040f2360ec32995b79ca9156 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Wed, 19 Aug 2026 20:23:55 +0000 Subject: [PATCH] chat: links open in a new tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit officer is a single-page app, so a same-tab navigation to an external url unloads it — tap a link in a message and you come back to a cold boot with the scroll position and anything mid-flight gone. `a` was simply absent from MD_COMPONENTS, so every link rendered as a bare anchor. all four ReactMarkdown call sites in MessageBubble share that object, so one override covers assistant messages, user messages, tool output and the collapsed blocks. noopener noreferrer with it: a bare target="_blank" hands the opened page a live window.opener back into the app. target is set AFTER the prop spread so raw html in a message cannot override it with _self. --- .../apps/Chat/components/MessageBubble.tsx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx b/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx index 914d7614..a8c31f74 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx @@ -1,3 +1,4 @@ +import type { ComponentProps } from 'react'; import { useState, useRef, useEffect } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; @@ -37,7 +38,23 @@ import { injectImages } from './chat-images'; import { randomId } from 'helpers/random-id'; /** Shared by the settled bubble and the streaming one, so a block gains nothing when the turn ends. */ -const MD_COMPONENTS = { pre: CodeBlock, img: ChatImage }; +/** + * Every link in a message opens in a new tab. + * + * Officer is a single-page app, so a same-tab navigation to an external URL unloads it: the user taps a + * link in a chat and comes back to a cold boot, having lost the scroll position and anything mid-flight. + * A message is a place someone reads FROM, not a place they navigate away from. + * + * `noopener noreferrer` is not decoration — a bare `target="_blank"` hands the opened page a live + * `window.opener` back into the app, which is a redirect away from anything an agent linked to. + */ +const ChatLink = ({ href, children, ...rest }: ComponentProps<'a'>) => ( + + {children} + +); + +const MD_COMPONENTS = { pre: CodeBlock, img: ChatImage, a: ChatLink }; const sanitizeSchema = { ...defaultSchema,