diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts
index be15c75587..f4b6033c43 100644
--- a/desktop/playwright.config.ts
+++ b/desktop/playwright.config.ts
@@ -61,6 +61,7 @@ export default defineConfig({
"**/doctor-cta-screenshots.spec.ts",
"**/pubkey-display-screenshots.spec.ts",
"**/file-attachment.spec.ts",
+ "**/markdown-doc-viewer.spec.ts",
"**/image-attachment-gallery.spec.ts",
"**/composer-image-draw.spec.ts",
"**/video-attachment.spec.ts",
diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx
index 89c61bdf60..87242f8b2c 100644
--- a/desktop/src/features/channels/ui/ChannelPane.tsx
+++ b/desktop/src/features/channels/ui/ChannelPane.tsx
@@ -30,6 +30,7 @@ import { useComposerHeightPadding } from "@/features/messages/ui/useComposerHeig
import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel";
import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThreadPanel";
import { ChannelManagementAuxiliaryPanel } from "@/features/channels/ui/ChannelManagementAuxiliaryPanel";
+import { MarkdownDocPanel } from "@/features/channels/ui/MarkdownDocPanel";
import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane";
import { ThreadViewModeToggle } from "@/features/channels/ui/ThreadViewModeToggle";
import { FocusThreadDrawer } from "@/features/channels/ui/FocusThreadDrawer";
@@ -149,6 +150,9 @@ export const ChannelPane = React.memo(function ChannelPane({
openAgentSessionPubkey,
onProfilePanelViewChange,
onProfilePanelTabChange,
+ markdownDocName,
+ markdownDocUrl,
+ onCloseMarkdownDoc,
profilePanelPubkey,
profilePanelTab,
profilePanelView,
@@ -538,13 +542,18 @@ export const ChannelPane = React.memo(function ChannelPane({
}),
[agentSessionAgents, openAgentSessionPubkey, profilePanelPubkey, profiles],
);
+ const openMarkdownDoc =
+ markdownDocUrl && markdownDocName
+ ? { filename: markdownDocName, url: markdownDocUrl }
+ : null;
const hasSplitAuxiliaryPane =
useSplitAuxiliaryPane &&
(channelManagementOpen ||
Boolean(threadHeadMessage) ||
shouldShowThreadSkeleton ||
Boolean(activeChannel && selectedAgent) ||
- Boolean(profilePanelPubkey));
+ Boolean(profilePanelPubkey) ||
+ Boolean(openMarkdownDoc));
const wrapAux = (
panel: React.ReactNode,
testId: string,
@@ -999,6 +1008,27 @@ export const ChannelPane = React.memo(function ChannelPane({
);
return wrapAux(panel, "user-profile-panel");
})()
+ ) : openMarkdownDoc && onCloseMarkdownDoc ? (
+ (() => {
+ // Lowest-priority pane: a higher-priority pane opened afterwards
+ // (thread, activity, profile) shows immediately, and the document
+ // reappears when it closes. Opening a document clears competitors
+ // in the screen-level handler, so it is never dead on arrival.
+ const panel = (
+
+ );
+ return wrapAux(panel, "markdown-doc-panel");
+ })()
) : null}
diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts
index d1b88ae3a6..adc9706c81 100644
--- a/desktop/src/features/channels/ui/ChannelPane.types.ts
+++ b/desktop/src/features/channels/ui/ChannelPane.types.ts
@@ -80,6 +80,7 @@ export type ChannelPaneProps = {
onCloseAgentSession: () => void;
onCloseChannelManagement?: () => void;
onChannelManagementDeleted?: () => void;
+ onCloseMarkdownDoc?: () => void;
onCloseProfilePanel: () => void;
onAddAgent?: (options?: { beforeSend?: () => void }) => void;
onBrowseChannels?: () => void;
@@ -164,6 +165,10 @@ export type ChannelPaneProps = {
tab: ProfilePanelTab,
options?: { replace?: boolean },
) => void;
+ /** Imeta filename labeling the markdown document viewer panel. */
+ markdownDocName?: string | null;
+ /** Relay media URL shown in the markdown document viewer panel. */
+ markdownDocUrl?: string | null;
profilePanelPubkey?: string | null;
profilePanelTab: ProfilePanelTab;
profilePanelView: ProfilePanelView;
diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx
index fe0c4d23b7..8f99e1f9f9 100644
--- a/desktop/src/features/channels/ui/ChannelScreen.tsx
+++ b/desktop/src/features/channels/ui/ChannelScreen.tsx
@@ -54,6 +54,10 @@ import { useHuddleReadMarker } from "@/features/channels/ui/useHuddleReadMarker"
import { useHuddleThreadIsolation } from "@/features/channels/ui/useHuddleThreadIsolation";
import { AgentSessionProvider } from "@/shared/context/AgentSessionContext";
import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext";
+import {
+ type MarkdownDocTarget,
+ MarkdownDocViewerProvider,
+} from "@/shared/ui/markdown/markdownDocViewerContext";
import { useMainInsetRef } from "@/shared/layout/MainInsetContext";
import { channelContentTopPaddingMeasurement } from "@/shared/layout/chromeLayout";
import { useMeasuredCssVariable } from "@/shared/layout/useMeasuredCssVariable";
@@ -114,6 +118,10 @@ export function ChannelScreen({
channelManagementOpen,
clearAutoSend,
clearMessageRouteTarget,
+ closeMarkdownDoc,
+ markdownDocName,
+ markdownDocUrl,
+ openMarkdownDoc,
openAgentSessionChannelId,
openAgentSessionPubkey,
openProfilePanel,
@@ -742,6 +750,28 @@ export function ChannelScreen({
() => setIsMembersSidebarOpen((prev) => !prev),
[],
);
+ // Opening a document clears competing panes (mirrors handleManageChannel /
+ // useChannelProfilePanel) so the doc panel — lowest in ChannelPane's pane
+ // priority chain — is never opened dead behind another pane.
+ const handleOpenMarkdownDoc = React.useCallback(
+ (doc: MarkdownDocTarget) => {
+ setOpenThreadHeadId(null);
+ setExpandedThreadReplyIds(new Set());
+ setThreadScrollTargetId(null);
+ setThreadReplyTargetId(null);
+ handleCloseAgentSession();
+ setChannelManagementOpen(false);
+ setProfilePanelPubkey(null);
+ openMarkdownDoc(doc.url, doc.filename);
+ },
+ [
+ handleCloseAgentSession,
+ openMarkdownDoc,
+ setChannelManagementOpen,
+ setOpenThreadHeadId,
+ setProfilePanelPubkey,
+ ],
+ );
const channelHeader = React.useMemo(
() => (
- }
- >
- {searchForwarding.renderSearchAwareChannel(
-
- knownAgentPubkeys.has(pubkey) ||
- !!messageProfiles?.[pubkey]?.isAgent,
- )
- : null
- }
- followThreadById={followThread}
- unfollowThreadById={unfollowThread}
- isFollowingThreadById={isFollowingThread}
- isMessageUnreadById={isMessageUnread}
- isFollowingThread={isNotifiedForEffectiveThread}
- isSending={sendMessageMutation.isPending}
- isSinglePanelView={isSinglePanelView}
- isTimelineLoading={isTimelineLoading}
- messages={timelineMessages}
- threadSummaries={threadSummaries}
- huddleThreadRepliesError={huddleThreadRepliesError}
- onRetryHuddleThreadReplies={onRetryHuddleThreadReplies}
- onCancelEdit={handleCancelEdit}
- onCancelThreadReply={handleCancelThreadReply}
- onChannelManagementDeleted={handleChannelManagementDeleted}
- onFollowThread={
- effectiveOpenThreadHeadId != null &&
- !isNotifiedForEffectiveThread
- ? () => followThread(effectiveOpenThreadHeadId)
- : undefined
- }
- onUnfollowThread={
- effectiveOpenThreadHeadId != null &&
- isNotifiedForEffectiveThread
- ? () => unfollowThread(effectiveOpenThreadHeadId)
- : undefined
- }
- onCloseAgentSession={handleCloseAgentSession}
- onBackFromAgentSession={
- hasAgentSessionReturnTarget
- ? handleBackFromAgentSession
- : undefined
- }
- onCloseChannelManagement={handleCloseChannelManagement}
- onCloseThread={handleCloseThread}
- onDelete={
- activeChannel?.archivedAt ? undefined : handleDelete
- }
- onEdit={activeChannel?.archivedAt ? undefined : handleEdit}
- onEditSave={
- activeChannel?.archivedAt ? undefined : handleEditSave
- }
- onMarkUnread={handleMessageMarkUnread}
- onMarkRead={handleMessageMarkRead}
- onExpandThreadReplies={handleExpandThreadReplies}
- onOpenAgentSession={handleOpenAgentSession}
- onOpenDm={handleOpenDm}
- onOpenProfilePanel={handleOpenProfilePanel}
- onResetThreadPanelWidth={handleThreadPanelWidthReset}
- onCloseProfilePanel={handleCloseProfilePanel}
- onOpenThread={handleOpenThreadAndCloseAgentSession}
- onSelectThreadReplyTarget={handleSelectThreadReplyTarget}
- onSendMessage={handleSendMessage}
- onSendToChannel={handleSendToChannel}
- onSendVideoReviewComment={effectiveSendVideoReviewComment}
- onSendThreadReply={handleSendThreadReply}
- onThreadScrollTargetResolved={() =>
- setThreadScrollTargetId(null)
- }
- onThreadPanelResizeStart={handleThreadPanelResizeStart}
- onTargetReached={() =>
- clearMessageRouteTarget({ replace: true })
- }
- onToggleReaction={effectiveToggleReaction}
- openAgentSessionChannelId={openAgentSessionChannelId}
- openAgentSessionPubkey={openAgentSessionPubkey}
- openThreadHeadId={effectiveOpenThreadHeadId}
- shouldShowThreadSkeleton={shouldShowThreadSkeleton}
- onProfilePanelViewChange={setProfilePanelView}
- onProfilePanelTabChange={setProfilePanelTab}
- profilePanelPubkey={profilePanelPubkey}
- profilePanelTab={profilePanelTab}
- profilePanelView={profilePanelView}
- personaLookup={personaLookup}
- profiles={messageProfiles}
- ownerProfiles={messageOwnerProfiles}
- firstUnreadMessageId={firstUnreadMessageId}
- unreadCount={unreadCount}
- targetMessageId={mainTimelineTargetMessageId}
- threadAllMessages={displayedThreadAllMessages}
- threadHeadMessage={displayedThreadHeadMessage}
- threadMessages={displayedThreadMessages}
- threadMessagesPending={threadRepliesQuery.isPending}
- threadMessagesError={threadRepliesQuery.isError}
- onRetryThreadReplies={() => {
- void threadRepliesQuery.refetch();
- }}
- threadPanelWidthPx={threadPanelWidthPx}
- threadTypingPubkeys={threadTypingPubkeys}
- threadReplyTargetMessage={displayedThreadReplyTargetMessage}
- threadScrollTargetId={threadScrollTargetId}
- threadUnreadCounts={threadUnreadCounts}
- threadReplyUnreadCounts={threadReplyUnreadCounts}
- threadFirstUnreadReplyId={displayedThreadFirstUnreadReplyId}
- isJoining={joinChannelMutation.isPending}
- onJoinChannel={joinChannelMutation.mutateAsync}
- typingPubkeys={humanTypingPubkeys}
- />,
- searchTarget,
- )}
-
+ // Scoped to the pane that hosts the markdown-doc auxiliary
+ // panel — the forum branch has no host, so its FileCards keep
+ // download behavior instead of a dead open-in-viewer click.
+
+
+ }
+ >
+ {searchForwarding.renderSearchAwareChannel(
+
+ knownAgentPubkeys.has(pubkey) ||
+ !!messageProfiles?.[pubkey]?.isAgent,
+ )
+ : null
+ }
+ followThreadById={followThread}
+ unfollowThreadById={unfollowThread}
+ isFollowingThreadById={isFollowingThread}
+ isMessageUnreadById={isMessageUnread}
+ isFollowingThread={isNotifiedForEffectiveThread}
+ isSending={sendMessageMutation.isPending}
+ isSinglePanelView={isSinglePanelView}
+ isTimelineLoading={isTimelineLoading}
+ messages={timelineMessages}
+ threadSummaries={threadSummaries}
+ huddleThreadRepliesError={huddleThreadRepliesError}
+ onRetryHuddleThreadReplies={onRetryHuddleThreadReplies}
+ onCancelEdit={handleCancelEdit}
+ onCancelThreadReply={handleCancelThreadReply}
+ onChannelManagementDeleted={
+ handleChannelManagementDeleted
+ }
+ onFollowThread={
+ effectiveOpenThreadHeadId != null &&
+ !isNotifiedForEffectiveThread
+ ? () => followThread(effectiveOpenThreadHeadId)
+ : undefined
+ }
+ onUnfollowThread={
+ effectiveOpenThreadHeadId != null &&
+ isNotifiedForEffectiveThread
+ ? () => unfollowThread(effectiveOpenThreadHeadId)
+ : undefined
+ }
+ onCloseAgentSession={handleCloseAgentSession}
+ onBackFromAgentSession={
+ hasAgentSessionReturnTarget
+ ? handleBackFromAgentSession
+ : undefined
+ }
+ onCloseChannelManagement={handleCloseChannelManagement}
+ onCloseThread={handleCloseThread}
+ onDelete={
+ activeChannel?.archivedAt ? undefined : handleDelete
+ }
+ onEdit={
+ activeChannel?.archivedAt ? undefined : handleEdit
+ }
+ onEditSave={
+ activeChannel?.archivedAt ? undefined : handleEditSave
+ }
+ onMarkUnread={handleMessageMarkUnread}
+ onMarkRead={handleMessageMarkRead}
+ onExpandThreadReplies={handleExpandThreadReplies}
+ onOpenAgentSession={handleOpenAgentSession}
+ onOpenDm={handleOpenDm}
+ onOpenProfilePanel={handleOpenProfilePanel}
+ onResetThreadPanelWidth={handleThreadPanelWidthReset}
+ onCloseProfilePanel={handleCloseProfilePanel}
+ onOpenThread={handleOpenThreadAndCloseAgentSession}
+ onSelectThreadReplyTarget={handleSelectThreadReplyTarget}
+ onSendMessage={handleSendMessage}
+ onSendToChannel={handleSendToChannel}
+ onSendVideoReviewComment={effectiveSendVideoReviewComment}
+ onSendThreadReply={handleSendThreadReply}
+ onThreadScrollTargetResolved={() =>
+ setThreadScrollTargetId(null)
+ }
+ onThreadPanelResizeStart={handleThreadPanelResizeStart}
+ onTargetReached={() =>
+ clearMessageRouteTarget({ replace: true })
+ }
+ onToggleReaction={effectiveToggleReaction}
+ openAgentSessionChannelId={openAgentSessionChannelId}
+ openAgentSessionPubkey={openAgentSessionPubkey}
+ openThreadHeadId={effectiveOpenThreadHeadId}
+ shouldShowThreadSkeleton={shouldShowThreadSkeleton}
+ onProfilePanelViewChange={setProfilePanelView}
+ onProfilePanelTabChange={setProfilePanelTab}
+ markdownDocName={markdownDocName}
+ markdownDocUrl={markdownDocUrl}
+ onCloseMarkdownDoc={closeMarkdownDoc}
+ profilePanelPubkey={profilePanelPubkey}
+ profilePanelTab={profilePanelTab}
+ profilePanelView={profilePanelView}
+ personaLookup={personaLookup}
+ profiles={messageProfiles}
+ ownerProfiles={messageOwnerProfiles}
+ firstUnreadMessageId={firstUnreadMessageId}
+ unreadCount={unreadCount}
+ targetMessageId={mainTimelineTargetMessageId}
+ threadAllMessages={displayedThreadAllMessages}
+ threadHeadMessage={displayedThreadHeadMessage}
+ threadMessages={displayedThreadMessages}
+ threadMessagesPending={threadRepliesQuery.isPending}
+ threadMessagesError={threadRepliesQuery.isError}
+ onRetryThreadReplies={() => {
+ void threadRepliesQuery.refetch();
+ }}
+ threadPanelWidthPx={threadPanelWidthPx}
+ threadTypingPubkeys={threadTypingPubkeys}
+ threadReplyTargetMessage={
+ displayedThreadReplyTargetMessage
+ }
+ threadScrollTargetId={threadScrollTargetId}
+ threadUnreadCounts={threadUnreadCounts}
+ threadReplyUnreadCounts={threadReplyUnreadCounts}
+ threadFirstUnreadReplyId={
+ displayedThreadFirstUnreadReplyId
+ }
+ isJoining={joinChannelMutation.isPending}
+ onJoinChannel={joinChannelMutation.mutateAsync}
+ typingPubkeys={humanTypingPubkeys}
+ />,
+ searchTarget,
+ )}
+
+
)
) : (
diff --git a/desktop/src/features/channels/ui/MarkdownDocPanel.tsx b/desktop/src/features/channels/ui/MarkdownDocPanel.tsx
new file mode 100644
index 0000000000..37721d2c52
--- /dev/null
+++ b/desktop/src/features/channels/ui/MarkdownDocPanel.tsx
@@ -0,0 +1,175 @@
+import * as React from "react";
+import { useQuery } from "@tanstack/react-query";
+import { Download, FileText, Loader2 } from "lucide-react";
+import { toast } from "sonner";
+
+import { invokeTauri } from "@/shared/api/tauri";
+import { fetchMediaBytes } from "@/shared/api/tauriMedia";
+import { useEscapeKey } from "@/shared/hooks/useEscapeKey";
+import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile";
+import {
+ AuxiliaryPanel,
+ AuxiliaryPanelBody,
+ AuxiliaryPanelHeader,
+ AuxiliaryPanelHeaderActions,
+ AuxiliaryPanelHeaderGroup,
+ AuxiliaryPanelHeaderTitleBlock,
+} from "@/shared/layout/AuxiliaryPanel";
+import { Button } from "@/shared/ui/button";
+import { Markdown, SyntaxHighlightedCode } from "@/shared/ui/markdown";
+import {
+ decodeMarkdownDocBytes,
+ type MarkdownDocDecodeResult,
+} from "@/shared/ui/markdown/markdownDocFile";
+import { SegmentedControl } from "@/shared/ui/segmented-control";
+
+type MarkdownDocView = "preview" | "code";
+
+type MarkdownDocPanelProps = {
+ /** Raw relay `/media/` URL of the attachment. */
+ url: string;
+ /** Human-readable filename from the imeta `filename` field. */
+ filename: string;
+ isSinglePanelView?: boolean;
+ layout?: "standalone" | "split";
+ onClose: () => void;
+ transparentChrome?: boolean;
+ widthPx: number;
+};
+
+const VIEW_OPTIONS = [
+ { value: "preview", label: "Preview" },
+ { value: "code", label: "Code" },
+] as const;
+
+function decodeErrorMessage(kind: "too-large" | "binary"): string {
+ return kind === "too-large"
+ ? "This file is too large to preview."
+ : "This file isn't valid text, so it can't be previewed.";
+}
+
+/**
+ * Right auxiliary panel rendering a shared markdown attachment in-app.
+ *
+ * Relay media URLs require relay auth (plain browser requests 401), so the
+ * content is fetched through the authenticated `fetch_media_bytes` Tauri
+ * command and rendered with the same markdown pipeline chat messages use.
+ * The Preview/Code toggle switches between rendered markdown and the
+ * syntax-highlighted source.
+ */
+export function MarkdownDocPanel({
+ url,
+ filename,
+ isSinglePanelView = false,
+ layout = "standalone",
+ onClose,
+ transparentChrome = false,
+ widthPx,
+}: MarkdownDocPanelProps) {
+ const isOverlay = useIsThreadPanelOverlay();
+ useEscapeKey(onClose, isOverlay || isSinglePanelView);
+ const [view, setView] = React.useState("preview");
+
+ // Blob URLs are content-addressed (`/media/{sha256}.{ext}`), so a fetched
+ // document never changes under its URL — cache it for the session.
+ const docQuery = useQuery({
+ queryKey: ["markdown-doc", url],
+ queryFn: async () => decodeMarkdownDocBytes(await fetchMediaBytes(url)),
+ staleTime: Number.POSITIVE_INFINITY,
+ retry: 1,
+ });
+
+ const handleDownload = React.useCallback(() => {
+ invokeTauri("download_file", { url, filename }).catch((err: unknown) => {
+ const msg = err instanceof Error ? err.message : "Download failed";
+ toast.error(msg);
+ });
+ }, [url, filename]);
+
+ const decoded = docQuery.data;
+ const errorMessage = docQuery.isError
+ ? "Couldn't load this file from the relay."
+ : decoded && decoded.kind !== "ok"
+ ? decodeErrorMessage(decoded.kind)
+ : null;
+
+ return (
+
+
+
+
+
+
+ {decoded?.kind === "ok" ? (
+
+ ) : null}
+
+
+
+ }
+ >
+
+ {docQuery.isPending ? (
+
+
+
+ ) : errorMessage !== null ? (
+
+
{errorMessage}
+
+
+ ) : decoded?.kind === "ok" ? (
+ view === "preview" ? (
+
+ ) : (
+
+
+
+ )
+ ) : null}
+
+
+ );
+}
diff --git a/desktop/src/features/channels/ui/channelSearchKeys.ts b/desktop/src/features/channels/ui/channelSearchKeys.ts
index 0828d9fec6..25b763a7c0 100644
--- a/desktop/src/features/channels/ui/channelSearchKeys.ts
+++ b/desktop/src/features/channels/ui/channelSearchKeys.ts
@@ -10,6 +10,8 @@ export const CHANNEL_SEARCH_KEYS = [
"agentSessionChannel",
"autoSend",
"channelManagement",
+ "doc",
+ "docName",
"messageId",
"profile",
"profileTab",
diff --git a/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts b/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts
index 60e3a291b2..4481d61160 100644
--- a/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts
+++ b/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts
@@ -29,7 +29,8 @@ export type { ChannelSearchKey } from "./channelSearchKeys";
* (presence flag for the channel-management panel — open/closed only, so it
* carries a sentinel `"1"` rather than an id), `autoSend` (draft auto-submit
* trigger — cleared surgically after the auto-submit fires so `thread` and
- * all other panel state are preserved).
+ * all other panel state are preserved), `doc` + `docName` (markdown document
+ * viewer panel — relay media URL and imeta filename).
*/
export type PanelSetterOptions = HistorySearchSetterOptions;
@@ -96,6 +97,21 @@ export function useChannelPanelHistoryState() {
[applyPatch],
);
+ // `doc` + `docName` travel together: the URL identifies the attachment and
+ // the imeta filename is the only human-readable name (blob URLs are content
+ // hashes), so a doc without a name can't label its panel.
+ const openMarkdownDoc = React.useCallback(
+ (url: string, filename: string, options?: PanelSetterOptions) =>
+ applyPatch({ doc: url, docName: filename }, options),
+ [applyPatch],
+ );
+
+ const closeMarkdownDoc = React.useCallback(
+ (options?: PanelSetterOptions) =>
+ applyPatch({ doc: null, docName: null }, options),
+ [applyPatch],
+ );
+
const setChannelManagementOpen = React.useCallback(
(open: boolean, options?: PanelSetterOptions) =>
applyPatch(
@@ -125,6 +141,10 @@ export function useChannelPanelHistoryState() {
channelManagementOpen: values.channelManagement != null,
clearAutoSend,
clearMessageRouteTarget,
+ closeMarkdownDoc,
+ markdownDocName: values.docName,
+ markdownDocUrl: values.doc,
+ openMarkdownDoc,
openAgentSessionChannelId: values.agentSessionChannel,
openAgentSessionPubkey: values.agentSession,
openProfilePanel,
diff --git a/desktop/src/shared/ui/markdown/FileCard.tsx b/desktop/src/shared/ui/markdown/FileCard.tsx
index 889847193e..38ee068be9 100644
--- a/desktop/src/shared/ui/markdown/FileCard.tsx
+++ b/desktop/src/shared/ui/markdown/FileCard.tsx
@@ -1,10 +1,15 @@
import * as React from "react";
-import { Download, FileText } from "lucide-react";
+import { Download, FileText, PanelRight } from "lucide-react";
import { toast } from "sonner";
import { invokeTauri } from "@/shared/api/tauri";
import { useSmoothCorners } from "@/shared/ui/smoothCorners";
+import { isRelayDownloadable } from "./mediaEntry";
+import { isMarkdownDocFilename } from "./markdownDocFile";
+import { useMarkdownDocViewer } from "./markdownDocViewerContext";
+import { useMarkdownRuntime } from "./runtimeContext";
+
/** Human-readable byte size: "820 B", "12.4 KB", "3.1 MB". */
function formatFileSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return "";
@@ -28,6 +33,11 @@ function formatFileSize(bytes: number): string {
* link navigates the webview to the blob URL, which escapes to the OS browser
* and gets bounced to a corporate CDN interstitial ("browser not supported").
* The native command mirrors the image-download path.
+ *
+ * Markdown attachments (`.md`/`.markdown`/`.mdx` by imeta filename) open the
+ * in-app markdown viewer panel instead when a hosting surface provides one —
+ * relay-hosted only, because the viewer fetches through the authenticated
+ * `fetch_media_bytes` command, which accepts relay `/media/` origins alone.
*/
export function FileCard({
href,
@@ -41,12 +51,22 @@ export function FileCard({
const cardRef = React.useRef(null);
const sizeLabel = size != null ? formatFileSize(size) : "";
useSmoothCorners(cardRef);
+ const openMarkdownDoc = useMarkdownDocViewer();
+ const { relayOrigin } = useMarkdownRuntime();
+ const opensInViewer =
+ openMarkdownDoc !== null &&
+ isMarkdownDocFilename(filename) &&
+ isRelayDownloadable(href, relayOrigin ?? undefined);
return (
);
}
diff --git a/desktop/src/shared/ui/markdown/markdownDocFile.test.mjs b/desktop/src/shared/ui/markdown/markdownDocFile.test.mjs
new file mode 100644
index 0000000000..58c7e05e91
--- /dev/null
+++ b/desktop/src/shared/ui/markdown/markdownDocFile.test.mjs
@@ -0,0 +1,62 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+
+import {
+ decodeMarkdownDocBytes,
+ isMarkdownDocFilename,
+ MAX_MARKDOWN_DOC_BYTES,
+} from "./markdownDocFile.ts";
+
+// ── isMarkdownDocFilename ─────────────────────────────────────────────────
+
+test("isMarkdownDocFilename: accepts .md, .markdown, .mdx", () => {
+ assert.equal(isMarkdownDocFilename("README.md"), true);
+ assert.equal(isMarkdownDocFilename("notes.markdown"), true);
+ assert.equal(isMarkdownDocFilename("page.mdx"), true);
+});
+
+test("isMarkdownDocFilename: case-insensitive and whitespace-tolerant", () => {
+ assert.equal(isMarkdownDocFilename("PLAN.MD"), true);
+ assert.equal(isMarkdownDocFilename(" design.Md "), true);
+});
+
+test("isMarkdownDocFilename: rejects other extensions", () => {
+ assert.equal(isMarkdownDocFilename("report.pdf"), false);
+ assert.equal(isMarkdownDocFilename("archive.zip"), false);
+ assert.equal(isMarkdownDocFilename("script.mjs"), false);
+ // Extension must be a suffix with a stem, not the whole name.
+ assert.equal(isMarkdownDocFilename(".md"), false);
+ assert.equal(isMarkdownDocFilename(""), false);
+});
+
+test("isMarkdownDocFilename: does not match mid-name extensions", () => {
+ assert.equal(isMarkdownDocFilename("notes.md.zip"), false);
+ assert.equal(isMarkdownDocFilename("mdfile.txt"), false);
+});
+
+// ── decodeMarkdownDocBytes ────────────────────────────────────────────────
+
+test("decodeMarkdownDocBytes: decodes UTF-8 text", () => {
+ const bytes = new TextEncoder().encode("# Hello 🐝\n\n- item");
+ assert.deepEqual(decodeMarkdownDocBytes(bytes), {
+ kind: "ok",
+ text: "# Hello 🐝\n\n- item",
+ });
+});
+
+test("decodeMarkdownDocBytes: rejects oversized payloads", () => {
+ const bytes = new Uint8Array(MAX_MARKDOWN_DOC_BYTES + 1);
+ assert.deepEqual(decodeMarkdownDocBytes(bytes), { kind: "too-large" });
+});
+
+test("decodeMarkdownDocBytes: accepts a payload exactly at the cap", () => {
+ const bytes = new Uint8Array(MAX_MARKDOWN_DOC_BYTES).fill(0x61);
+ const result = decodeMarkdownDocBytes(bytes);
+ assert.equal(result.kind, "ok");
+});
+
+test("decodeMarkdownDocBytes: strict decode reports binary content", () => {
+ // 0xFF is never valid in UTF-8.
+ const bytes = new Uint8Array([0x23, 0x20, 0xff, 0xfe, 0x00]);
+ assert.deepEqual(decodeMarkdownDocBytes(bytes), { kind: "binary" });
+});
diff --git a/desktop/src/shared/ui/markdown/markdownDocFile.ts b/desktop/src/shared/ui/markdown/markdownDocFile.ts
new file mode 100644
index 0000000000..7e9dbf06aa
--- /dev/null
+++ b/desktop/src/shared/ui/markdown/markdownDocFile.ts
@@ -0,0 +1,57 @@
+/**
+ * Pure classification and decoding for viewable markdown document
+ * attachments.
+ *
+ * A markdown file uploaded to the relay has no magic bytes, so Blossom
+ * stores it as `application/octet-stream` under a `{sha256}.bin` blob key —
+ * the original `.md` name survives only in the message's imeta `filename`
+ * field. Classification therefore keys off the imeta filename, never the
+ * blob-URL extension or the MIME type.
+ *
+ * Kept DOM-free (TextDecoder is available in both the webview and Node)
+ * so the branch logic is unit-testable without a webview.
+ */
+
+/** Filename extensions rendered by the in-app markdown viewer. */
+const MARKDOWN_DOC_EXTENSIONS = [".md", ".markdown", ".mdx"] as const;
+
+/**
+ * Maximum attachment size the viewer will render. Larger files fall back
+ * to the download card path. Matches the ceiling proven workable by other
+ * in-app text viewers; well under the 50 MiB `fetch_media_bytes` cap.
+ */
+export const MAX_MARKDOWN_DOC_BYTES = 2 * 1024 * 1024;
+
+/** Whether an imeta filename should open in the in-app markdown viewer. */
+export function isMarkdownDocFilename(filename: string): boolean {
+ const lower = filename.trim().toLowerCase();
+ return MARKDOWN_DOC_EXTENSIONS.some(
+ (extension) => lower.endsWith(extension) && lower.length > extension.length,
+ );
+}
+
+export type MarkdownDocDecodeResult =
+ | { kind: "ok"; text: string }
+ | { kind: "too-large" }
+ | { kind: "binary" };
+
+/**
+ * Decode fetched attachment bytes for the viewer.
+ *
+ * Strict UTF-8: a file that merely *claims* to be markdown by name but is
+ * actually binary fails decoding and reports `binary`, so the panel can fall
+ * back to the download action instead of rendering mojibake.
+ */
+export function decodeMarkdownDocBytes(
+ bytes: Uint8Array,
+): MarkdownDocDecodeResult {
+ if (bytes.byteLength > MAX_MARKDOWN_DOC_BYTES) {
+ return { kind: "too-large" };
+ }
+ try {
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
+ return { kind: "ok", text };
+ } catch {
+ return { kind: "binary" };
+ }
+}
diff --git a/desktop/src/shared/ui/markdown/markdownDocViewerContext.ts b/desktop/src/shared/ui/markdown/markdownDocViewerContext.ts
new file mode 100644
index 0000000000..8195d93f35
--- /dev/null
+++ b/desktop/src/shared/ui/markdown/markdownDocViewerContext.ts
@@ -0,0 +1,31 @@
+import * as React from "react";
+
+/** A relay-hosted markdown attachment the in-app viewer can open. */
+export type MarkdownDocTarget = {
+ /** Raw relay `/media/` URL of the attachment (pre-proxy-rewrite). */
+ url: string;
+ /** Human-readable filename from the message's imeta `filename` field. */
+ filename: string;
+};
+
+/**
+ * Open-in-viewer callback for markdown document attachments.
+ *
+ * Provided by surfaces that host a markdown-doc auxiliary panel (the channel
+ * screen). Where no provider exists — project PR/issue bodies, read-only
+ * previews — the context is `null` and `FileCard` keeps its default
+ * download-only behavior, so the viewer affordance can never appear
+ * somewhere it has no panel to open.
+ */
+const MarkdownDocViewerContext = React.createContext<
+ ((doc: MarkdownDocTarget) => void) | null
+>(null);
+
+export const MarkdownDocViewerProvider = MarkdownDocViewerContext.Provider;
+
+/** The active surface's open-in-viewer callback, or null when unhosted. */
+export function useMarkdownDocViewer():
+ | ((doc: MarkdownDocTarget) => void)
+ | null {
+ return React.useContext(MarkdownDocViewerContext);
+}
diff --git a/desktop/tests/e2e/markdown-doc-viewer.spec.ts b/desktop/tests/e2e/markdown-doc-viewer.spec.ts
new file mode 100644
index 0000000000..ecc1ec6191
--- /dev/null
+++ b/desktop/tests/e2e/markdown-doc-viewer.spec.ts
@@ -0,0 +1,187 @@
+import { expect, test } from "@playwright/test";
+import type { Page } from "@playwright/test";
+
+import { installMockBridge } from "../helpers/bridge";
+
+// Exercises the markdown-attachment viewer end-to-end through the mock Tauri
+// bridge: upload a `.md` file → send → FileCard opens the in-app markdown
+// viewer panel (not the download dialog) → Preview renders, Code shows the
+// source, Download still works from the panel header.
+//
+// The attachment URL deliberately mirrors production shape: the relay stores
+// extension-less text as `{sha256}.bin` (markdown has no magic bytes), so the
+// `.md` identity lives only in the imeta filename. The mock upload descriptor
+// reproduces that.
+
+const RELAY_HTTP_URL =
+ process.env.BUZZ_E2E_RELAY_URL ?? "http://localhost:3000";
+const DOC_SHA = "b".repeat(64);
+const DOC_URL = `${RELAY_HTTP_URL}/media/${DOC_SHA}.bin`;
+const DOC_MARKDOWN = [
+ "# Release Notes",
+ "",
+ "Some **bold** text and a table:",
+ "",
+ "| Feature | Works |",
+ "| --- | --- |",
+ "| Headings | Yes |",
+ "",
+ "```js",
+ 'console.log("hi");',
+ "```",
+ "",
+].join("\n");
+
+test.beforeEach(async ({ page }) => {
+ await installMockBridge(page, {
+ // Route attach through the DOM file input (Playwright's filechooser)
+ // instead of the native pick_and_upload_media dialog path.
+ deferredComposerUploads: true,
+ uploadDescriptors: [
+ {
+ url: DOC_URL,
+ sha256: DOC_SHA,
+ size: DOC_MARKDOWN.length,
+ type: "application/octet-stream",
+ uploaded: Math.floor(Date.now() / 1000),
+ filename: "release-notes.md",
+ },
+ ],
+ });
+ // The bridge's `fetch_media_bytes` mock fetches the URL in-browser; serve
+ // the document body from the spec instead of a real relay.
+ await page.route(`**/media/${DOC_SHA}.bin`, (route) =>
+ route.fulfill({
+ body: DOC_MARKDOWN,
+ contentType: "application/octet-stream",
+ }),
+ );
+});
+
+async function sendMarkdownAttachment(page: Page) {
+ await page.goto("/");
+ await page.getByTestId("channel-general").click();
+ await expect(page.getByTestId("chat-title")).toHaveText("general");
+
+ const [chooser] = await Promise.all([
+ page.waitForEvent("filechooser"),
+ page.getByRole("button", { name: "Attach file" }).click(),
+ ]);
+ await chooser.setFiles({
+ buffer: Buffer.from(DOC_MARKDOWN),
+ mimeType: "text/markdown",
+ name: "release-notes.md",
+ });
+ await expect(page.getByTestId("message-composer")).toContainText(
+ "release-notes.md",
+ );
+ await page.getByTestId("send-message").click();
+ await expect(page.getByText("Sending")).toHaveCount(0);
+}
+
+test("markdown attachment opens the in-app viewer with Preview/Code toggle", async ({
+ page,
+}) => {
+ await sendMarkdownAttachment(page);
+
+ // The card advertises open-in-viewer, not download.
+ const card = page.getByTestId("file-card").last();
+ await expect(card).toContainText("release-notes.md");
+ await expect(card).toHaveAttribute("aria-label", "Open release-notes.md");
+ await card.click();
+
+ // The viewer panel opens with the rendered document (no download dialog).
+ const panel = page.getByTestId("markdown-doc-panel");
+ await expect(panel).toBeVisible();
+ await expect(panel).toContainText("release-notes.md");
+ await expect(
+ panel.getByRole("heading", { name: "Release Notes" }),
+ ).toBeVisible();
+ // GFM table rendered as a real table, not pipes.
+ await expect(panel.locator("table")).toContainText("Headings");
+ const commands = () =>
+ page.evaluate(
+ () =>
+ (window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
+ .__BUZZ_E2E_COMMANDS__ ?? [],
+ );
+ expect(await commands()).not.toContain("download_file");
+
+ // Code view shows the raw source.
+ await page.getByTestId("markdown-doc-view-code").click();
+ await expect(page.getByTestId("markdown-doc-code")).toContainText(
+ "# Release Notes",
+ );
+ await page.getByTestId("markdown-doc-view-preview").click();
+ await expect(
+ panel.getByRole("heading", { name: "Release Notes" }),
+ ).toBeVisible();
+
+ // Download stays available from the panel header.
+ await page.getByTestId("markdown-doc-download").click();
+ await expect.poll(commands).toContain("download_file");
+
+ // Close returns to the plain channel view.
+ await page.getByTestId("auxiliary-panel-close").click();
+ await expect(page.getByTestId("markdown-doc-panel")).toHaveCount(0);
+});
+
+test("non-markdown attachments keep the download-card behavior", async ({
+ page,
+}) => {
+ await page.goto("/");
+ await page.getByTestId("channel-general").click();
+
+ // Re-point the mock upload at a PDF: same flow, no viewer affordance.
+ await page.evaluate(() => {
+ const e2e = (
+ window as Window & {
+ __BUZZ_E2E__?: {
+ mock?: { uploadDescriptors?: Array> };
+ };
+ }
+ ).__BUZZ_E2E__;
+ if (e2e?.mock) {
+ e2e.mock.uploadDescriptors = [
+ {
+ url: `http://localhost:3000/media/${"c".repeat(64)}.pdf`,
+ sha256: "c".repeat(64),
+ size: 128,
+ type: "application/pdf",
+ uploaded: 1_700_000_000,
+ filename: "report.pdf",
+ },
+ ];
+ }
+ });
+
+ const [chooser] = await Promise.all([
+ page.waitForEvent("filechooser"),
+ page.getByRole("button", { name: "Attach file" }).click(),
+ ]);
+ await chooser.setFiles({
+ buffer: Buffer.from("pdf bytes"),
+ mimeType: "application/pdf",
+ name: "report.pdf",
+ });
+ await expect(page.getByTestId("message-composer")).toContainText(
+ "report.pdf",
+ );
+ await page.getByTestId("send-message").click();
+ await expect(page.getByText("Sending")).toHaveCount(0);
+
+ const card = page.getByTestId("file-card").last();
+ await expect(card).toContainText("report.pdf");
+ await expect(card).toHaveAttribute("aria-label", "Download report.pdf");
+ await card.click();
+ await expect
+ .poll(() =>
+ page.evaluate(
+ () =>
+ (window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
+ .__BUZZ_E2E_COMMANDS__ ?? [],
+ ),
+ )
+ .toContain("download_file");
+ await expect(page.getByTestId("markdown-doc-panel")).toHaveCount(0);
+});