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,