fix(desktop): make markdown viewer context-aware

Signed-off-by: Clay Delk <clay.delk@gmail.com>
This commit is contained in:
Clay Delk
2026-09-04 17:23:35 -04:00
parent e5dc6dcb22
commit 032d2bd4d6
17 changed files with 471 additions and 102 deletions

View File

@@ -0,0 +1,47 @@
import type * as React from "react";
import { MarkdownDocAuxiliaryPanel } from "@/features/channels/ui/MarkdownDocAuxiliaryPanel";
import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane";
import type { MarkdownDocTarget } from "@/shared/ui/markdown/markdownDocViewerContext";
type ChannelMarkdownDocPanelProps = {
canResetWidth: boolean;
doc: MarkdownDocTarget;
onClose: () => void;
onResetWidth: () => void;
onResizeStart: (event: React.PointerEvent<HTMLButtonElement>) => void;
stacked?: boolean;
widthPx: number;
};
/** Document pane shown beside, or stacked over, a still-mounted thread. */
export function ChannelMarkdownDocPanel({
canResetWidth,
doc,
onClose,
onResetWidth,
onResizeStart,
stacked = false,
widthPx,
}: ChannelMarkdownDocPanelProps) {
return (
<RightAuxiliaryPane
canResetWidth={canResetWidth}
className={stacked ? "absolute inset-y-0 right-0 z-41" : undefined}
onResetWidth={onResetWidth}
onResizeStart={onResizeStart}
testId={
stacked ? "markdown-doc-stacked-panel" : "markdown-doc-third-panel"
}
widthPx={widthPx}
>
<MarkdownDocAuxiliaryPanel
doc={doc}
isSinglePanelView={false}
onClose={onClose}
useSplitAuxiliaryPane
widthPx={widthPx}
/>
</RightAuxiliaryPane>
);
}

View File

@@ -0,0 +1,51 @@
import type * as React from "react";
import { AnimatePresence } from "motion/react";
import { ChannelMarkdownDocPanel } from "@/features/channels/ui/ChannelMarkdownDocPanels";
import type { MarkdownDocTarget } from "@/shared/ui/markdown/markdownDocViewerContext";
type Props = {
canResetThreadPanelWidth: boolean;
idleAuxiliarySurface: React.ReactNode;
onCloseMarkdownDoc?: () => void;
onResetThreadPanelWidth: () => void;
onThreadPanelResizeStart: (
event: React.PointerEvent<HTMLButtonElement>,
) => void;
openMarkdownDoc: MarkdownDocTarget | null;
showIdleAuxiliaryOverThread: boolean;
showMarkdownBesideThread: boolean;
threadPanelWidthPx: number;
threadSurface: { markExitComplete: () => void };
useStackedMarkdownPanel: boolean;
};
/** Presence boundaries for responsive Markdown panes around an open thread. */
export function ChannelMarkdownDocSurfaces(props: Props) {
const renderPanel = (stacked = false) =>
props.openMarkdownDoc && props.onCloseMarkdownDoc ? (
<ChannelMarkdownDocPanel
canResetWidth={props.canResetThreadPanelWidth}
doc={props.openMarkdownDoc}
onClose={props.onCloseMarkdownDoc}
onResetWidth={props.onResetThreadPanelWidth}
onResizeStart={props.onThreadPanelResizeStart}
stacked={stacked}
widthPx={props.threadPanelWidthPx}
/>
) : null;
return (
<>
<AnimatePresence>
{props.showMarkdownBesideThread ? renderPanel() : null}
</AnimatePresence>
<AnimatePresence onExitComplete={props.threadSurface.markExitComplete}>
{props.useStackedMarkdownPanel
? renderPanel(true)
: props.showIdleAuxiliaryOverThread
? props.idleAuxiliarySurface
: null}
</AnimatePresence>
</>
);
}

View File

@@ -30,6 +30,7 @@ import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThre
import { ChannelManagementAuxiliaryPanel } from "@/features/channels/ui/ChannelManagementAuxiliaryPanel";
import { IdleAuxiliaryPanel } from "@/features/channels/ui/IdleAuxiliaryPanel";
import { MarkdownDocAuxiliaryPanel } from "@/features/channels/ui/MarkdownDocAuxiliaryPanel";
import { ChannelMarkdownDocSurfaces } from "@/features/channels/ui/ChannelMarkdownDocSurfaces";
import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane";
import { createChannelPaneAuxiliaryLayout } from "@/features/channels/ui/channelPaneAuxiliaryLayout";
import {
@@ -43,6 +44,7 @@ import { getThreadPanelLayout } from "@/features/channels/lib/threadPanelLayout"
import { useThreadViewMode } from "@/features/channels/lib/threadViewModePreference";
import { useThreadViewModeSwitch } from "@/features/channels/ui/useThreadViewModeSwitch";
import { useFocusDrawerPresence } from "@/features/channels/ui/useFocusDrawerPresence";
import { AUXILIARY_PANEL_MIN_WIDTH_PX } from "@/shared/layout/AuxiliaryPanel";
import { useChannelWorkingAgentPubkeys } from "@/features/agents/agentWorkingSignal";
import { useCardMintJobs } from "@/features/agents/cardMintStore";
import { BotActivityComposerAction } from "@/features/channels/ui/BotActivityBar";
@@ -85,6 +87,7 @@ export const ChannelPane = React.memo(function ChannelPane({
onAutoSendComplete = null,
botTypingEntries,
channelManagementOpen = false,
channelContentWidthPx,
currentPubkey,
editTarget = null,
fetchOlder,
@@ -437,10 +440,15 @@ export const ChannelPane = React.memo(function ChannelPane({
priorityIdleAuxiliary,
replaceThreadWithIdleAuxiliary,
showIdleAuxiliaryOverThread,
showMarkdownBesideThread,
useFocusIdleDrawer,
useFocusThreadDrawer,
useStackedMarkdownPanel,
useSplitAuxiliaryPane,
} = createChannelPaneAuxiliaryLayout({
canFitThirdPanel:
channelContentWidthPx >=
threadPanelWidthPx * 2 + AUXILIARY_PANEL_MIN_WIDTH_PX,
channelManagementOpen,
hasAgentSession: Boolean(activeChannel && selectedAgent),
hasIdleAuxiliaryPanel: Boolean(idleAuxiliaryPanel),
@@ -456,14 +464,16 @@ export const ChannelPane = React.memo(function ChannelPane({
});
const { channelIsCovered, markExitComplete } = useFocusDrawerPresence(
useFocusThreadDrawer || useFocusIdleDrawer,
priorityIdleAuxiliary
? (onCloseIdleAuxiliaryPanel ?? onCloseThread)
: useFocusThreadDrawer
? onCloseThread
: (onCloseIdleAuxiliaryPanel ?? onCloseThread),
useStackedMarkdownPanel && onCloseMarkdownDoc
? onCloseMarkdownDoc
: priorityIdleAuxiliary
? (onCloseIdleAuxiliaryPanel ?? onCloseThread)
: useFocusThreadDrawer
? onCloseThread
: (onCloseIdleAuxiliaryPanel ?? onCloseThread),
);
const threadSurface = useThreadPanelSurface(
showIdleAuxiliaryOverThread,
showIdleAuxiliaryOverThread || useStackedMarkdownPanel,
markExitComplete,
);
const { changeThreadViewMode, layoutScrollTargetId, resolveScrollTarget } =
@@ -989,9 +999,8 @@ export const ChannelPane = React.memo(function ChannelPane({
idleAuxiliarySurface
)}
</AnimatePresence>
<AnimatePresence onExitComplete={threadSurface.markExitComplete}>
{showIdleAuxiliaryOverThread ? idleAuxiliarySurface : null}
</AnimatePresence>
{/* biome-ignore format: line-count ratchet in this legacy component */}
<ChannelMarkdownDocSurfaces {...{ openMarkdownDoc, onCloseMarkdownDoc, threadSurface, canResetThreadPanelWidth, onResetThreadPanelWidth, onThreadPanelResizeStart, showMarkdownBesideThread, useStackedMarkdownPanel, showIdleAuxiliaryOverThread, idleAuxiliarySurface, threadPanelWidthPx }} />
</div>
);
});

View File

@@ -36,6 +36,8 @@ export type ChannelPaneProps = {
onAutoSendComplete?: (() => void) | null;
botTypingEntries: TypingIndicatorEntry[];
channelManagementOpen?: boolean;
/** Width of the channel content container, used for responsive pane topology. */
channelContentWidthPx: number;
currentPubkey?: string;
editTarget?: MessageComposerEditTarget | null;
fetchOlder?: () => Promise<void>;

View File

@@ -833,6 +833,7 @@ export function ChannelScreen({
<GuardedChannelPane
onOpenMarkdownDoc={handleOpenMarkdownDoc}
activeChannel={activeChannel}
channelContentWidthPx={channelContentWidthPx}
activityAgents={channelAgentSessionAgents}
agentPubkeys={agentPubkeys}
agentPubkeysPending={agentPubkeysPending}

View File

@@ -3,6 +3,8 @@ import type { MarkdownDocTarget } from "@/shared/ui/markdown/markdownDocViewerCo
type MarkdownDocAuxiliaryPanelProps = {
doc: MarkdownDocTarget;
/** Render chrome for the full focus drawer rather than a narrow pane. */
isFocusDrawer?: boolean;
isSinglePanelView: boolean;
onClose: () => void;
useSplitAuxiliaryPane: boolean;
@@ -21,6 +23,7 @@ type MarkdownDocAuxiliaryPanelProps = {
*/
export function MarkdownDocAuxiliaryPanel({
doc,
isFocusDrawer = false,
isSinglePanelView,
onClose,
useSplitAuxiliaryPane,
@@ -32,10 +35,13 @@ export function MarkdownDocAuxiliaryPanel({
<MarkdownDocPanel
key={doc.url}
filename={doc.filename}
isSinglePanelView={useSplitAuxiliaryPane ? false : isSinglePanelView}
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
isFocusMode={isFocusDrawer}
isSinglePanelView={
isFocusDrawer || useSplitAuxiliaryPane ? false : isSinglePanelView
}
layout={useSplitAuxiliaryPane && !isFocusDrawer ? "split" : "standalone"}
onClose={onClose}
transparentChrome={useSplitAuxiliaryPane}
transparentChrome={useSplitAuxiliaryPane && !isFocusDrawer}
url={doc.url}
widthPx={widthPx}
/>

View File

@@ -14,6 +14,7 @@ import {
} from "@/features/channels/ui/markdownDocFocus";
import { useEscapeKey } from "@/shared/hooks/useEscapeKey";
import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile";
import { cn } from "@/shared/lib/cn";
import {
AuxiliaryPanel,
AuxiliaryPanelBody,
@@ -37,6 +38,8 @@ type MarkdownDocPanelProps = {
url: string;
/** Human-readable filename from the imeta `filename` field. */
filename: string;
/** Fill a parent focus drawer and center the document reading column. */
isFocusMode?: boolean;
isSinglePanelView?: boolean;
layout?: "standalone" | "split";
onClose: () => void;
@@ -68,6 +71,7 @@ function decodeErrorMessage(kind: "too-large" | "binary"): string {
export function MarkdownDocPanel({
url,
filename,
isFocusMode = false,
isSinglePanelView = false,
layout = "standalone",
onClose,
@@ -125,7 +129,8 @@ export function MarkdownDocPanel({
return (
<AuxiliaryPanel
isSinglePanelView={isSinglePanelView}
className={isFocusMode ? "w-full" : undefined}
isSinglePanelView={isFocusMode || isSinglePanelView}
layout={layout}
onClose={onClose}
testId="markdown-doc-panel"
@@ -155,13 +160,21 @@ export function MarkdownDocPanel({
</AuxiliaryPanelHeader>
}
>
<AuxiliaryPanelBody className="flex min-h-0 flex-col" panelPadding>
<AuxiliaryPanelBody
className="flex min-h-0 flex-col"
panelPadding={!isFocusMode}
>
{/* The view picker gets its own pinned row below the title: sharing
the title row squeezed the filename out, and the header chrome
band overlays anything placed directly after it in the header
slot — so the row lives inside the chrome-padded body instead. */}
{decoded?.kind === "ok" ? (
<div className="flex shrink-0 items-center px-4 pb-2">
<div
className={cn(
"flex w-full shrink-0 items-center px-4 pb-2",
isFocusMode && "mx-auto max-w-[55rem]",
)}
>
<SegmentedControl
legend="Document view"
onValueChange={setView}
@@ -174,43 +187,45 @@ export function MarkdownDocPanel({
</div>
) : null}
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-6">
{docQuery.isPending ? (
<div
className="flex items-center justify-center py-12"
data-testid="markdown-doc-loading"
>
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground/70" />
</div>
) : errorMessage !== null ? (
<div className="flex flex-col items-center gap-3 py-12 text-center">
<p className="text-sm text-muted-foreground">{errorMessage}</p>
<Button onClick={handleDownload} size="sm" variant="secondary">
<Download className="mr-1.5 h-4 w-4" />
Download file
</Button>
</div>
) : decoded?.kind === "ok" ? (
view === "preview" ? (
<Markdown
blockCode
className="pt-3 text-sm"
content={decoded.text}
hardLineBreaks={false}
/>
) : (
<pre
className="overflow-x-auto pt-3 text-xs leading-relaxed"
data-testid="markdown-doc-code"
<div className={cn("w-full", isFocusMode && "mx-auto max-w-[55rem]")}>
{docQuery.isPending ? (
<div
className="flex items-center justify-center py-12"
data-testid="markdown-doc-loading"
>
{/* Shiki's synchronous-tokenization guard caps highlighting at
150 lines; longer documents render as plain text here. */}
<SyntaxHighlightedCode
code={decoded.text}
language="markdown"
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground/70" />
</div>
) : errorMessage !== null ? (
<div className="flex flex-col items-center gap-3 py-12 text-center">
<p className="text-sm text-muted-foreground">{errorMessage}</p>
<Button onClick={handleDownload} size="sm" variant="secondary">
<Download className="mr-1.5 h-4 w-4" />
Download file
</Button>
</div>
) : decoded?.kind === "ok" ? (
view === "preview" ? (
<Markdown
blockCode
className="pt-3 text-sm"
content={decoded.text}
hardLineBreaks={false}
/>
</pre>
)
) : null}
) : (
<pre
className="overflow-x-auto pt-3 text-xs leading-relaxed"
data-testid="markdown-doc-code"
>
{/* Shiki's synchronous-tokenization guard caps highlighting at
150 lines; longer documents render as plain text here. */}
<SyntaxHighlightedCode
code={decoded.text}
language="markdown"
/>
</pre>
)
) : null}
</div>
</div>
</AuxiliaryPanelBody>
</AuxiliaryPanel>

View File

@@ -4,6 +4,7 @@ import test from "node:test";
import { createChannelPaneAuxiliaryLayout } from "./channelPaneAuxiliaryLayout.ts";
const base = {
canFitThirdPanel: false,
channelManagementOpen: false,
hasAgentSession: false,
hasIdleAuxiliaryPanel: true,
@@ -28,6 +29,35 @@ test("an open document suppresses idle-drawer coverage when the idle pane yields
assert.equal(layout.useFocusIdleDrawer, false);
});
test("an open document stacks over an existing split thread", () => {
const layout = createChannelPaneAuxiliaryLayout({
...base,
hasThreadSurface: true,
});
assert.equal(layout.useStackedMarkdownPanel, true);
assert.deepEqual(layout.openMarkdownDoc, {
filename: "notes.md",
url: "http://localhost/media/notes.bin",
});
});
test("a wide layout shows the document beside the existing thread", () => {
const layout = createChannelPaneAuxiliaryLayout({
...base,
canFitThirdPanel: true,
hasThreadSurface: true,
});
assert.equal(layout.showMarkdownBesideThread, true);
assert.equal(layout.useStackedMarkdownPanel, false);
});
test("a document without a thread remains in the ordinary auxiliary pane", () => {
const layout = createChannelPaneAuxiliaryLayout(base);
assert.equal(layout.useStackedMarkdownPanel, false);
});
test("an explicitly selected idle pane wins rendering and owns drawer coverage", () => {
const layout = createChannelPaneAuxiliaryLayout({
...base,

View File

@@ -4,6 +4,7 @@ import {
} from "./ChannelPane.helpers";
type ChannelPaneAuxiliaryLayoutOptions = {
canFitThirdPanel: boolean;
channelManagementOpen: boolean;
hasAgentSession: boolean;
hasIdleAuxiliaryPanel: boolean;
@@ -19,6 +20,7 @@ type ChannelPaneAuxiliaryLayoutOptions = {
};
export function createChannelPaneAuxiliaryLayout({
canFitThirdPanel,
channelManagementOpen,
hasAgentSession,
hasIdleAuxiliaryPanel,
@@ -61,6 +63,18 @@ export function createChannelPaneAuxiliaryLayout({
});
const displayedMarkdownDoc = priorityIdleAuxiliary ? null : openMarkdownDoc;
const showMarkdownBesideThread = Boolean(
displayedMarkdownDoc &&
hasThreadSurface &&
useSplitAuxiliaryPane &&
canFitThirdPanel,
);
const useStackedMarkdownPanel = Boolean(
displayedMarkdownDoc &&
hasThreadSurface &&
useSplitAuxiliaryPane &&
!showMarkdownBesideThread,
);
const hasSplitAuxiliaryPane =
useSplitAuxiliaryPane &&
(channelManagementOpen ||
@@ -75,8 +89,10 @@ export function createChannelPaneAuxiliaryLayout({
replaceThreadWithIdleAuxiliary,
showIdleAuxiliaryOverThread:
overlayIdleAuxiliaryOverThread && useFocusIdleDrawer,
showMarkdownBesideThread,
useFocusIdleDrawer,
useFocusThreadDrawer,
useStackedMarkdownPanel,
useSplitAuxiliaryPane,
};
}

View File

@@ -80,22 +80,37 @@ export function useChannelPaneOpeners({
const handleOpenMarkdownDoc = React.useCallback(
(doc: MarkdownDocTarget) => {
// Opening a doc closes the thread pane, so an in-progress thread edit
// must be resolved first — same contract as the sibling pane openers.
if (!requireThreadEditResolution()) return;
// Capture the invoking card's identity before the panel swap unmounts
// it, so close can restore focus to this card and not merely the first
// card sharing the document URL.
// Capture the invoking card's identity before a narrow-layout panel swap
// can unmount it. In wide layouts an open thread remains mounted beneath
// the document focus drawer, preserving its scroll and reply state.
recordMarkdownDocOpener(doc.url, doc.opener ?? null);
clearCompetingPanes();
// A document opened from the center timeline takes the ordinary right
// pane, replacing any open thread. A thread card (or URL-restored Inbox
// context, which has no live opener element) preserves that thread so
// ChannelPane can choose side-by-side vs stacked responsively.
if (
doc.opener &&
!doc.opener.closest('[data-testid="message-thread-panel"]')
) {
setOpenThreadHeadId(null);
setExpandedThreadReplyIds(new Set());
setThreadScrollTargetId(null);
setThreadReplyTargetId(null);
}
closeAgentSession();
setProfilePanelPubkey(null);
setChannelManagementOpen(false);
openMarkdownDoc(doc.url, doc.filename);
},
[
clearCompetingPanes,
closeAgentSession,
openMarkdownDoc,
requireThreadEditResolution,
setChannelManagementOpen,
setExpandedThreadReplyIds,
setOpenThreadHeadId,
setProfilePanelPubkey,
setThreadReplyTargetId,
setThreadScrollTargetId,
],
);

View File

@@ -0,0 +1,48 @@
import type * as React from "react";
import { MarkdownDocAuxiliaryPanel } from "@/features/channels/ui/MarkdownDocAuxiliaryPanel";
import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane";
import type { MarkdownDocTarget } from "@/shared/ui/markdown/markdownDocViewerContext";
type HomeMarkdownDocPanelProps = {
canResetWidth: boolean;
className?: string;
doc: MarkdownDocTarget;
onClose: () => void;
onResetWidth: () => void;
onResizeStart: (event: React.PointerEvent<HTMLButtonElement>) => void;
testId: string;
widthPx: number;
};
/** Inbox-local Markdown viewer, rendered either as a third pane or a stack. */
export function HomeMarkdownDocPanel({
canResetWidth,
className,
doc,
onClose,
onResetWidth,
onResizeStart,
testId,
widthPx,
}: HomeMarkdownDocPanelProps) {
return (
<RightAuxiliaryPane
canResetWidth={canResetWidth}
className={className}
constrainToAvailableSpace={false}
onResetWidth={onResetWidth}
onResizeStart={onResizeStart}
testId={testId}
widthPx={widthPx}
>
<MarkdownDocAuxiliaryPanel
doc={doc}
isSinglePanelView={false}
onClose={onClose}
useSplitAuxiliaryPane
widthPx={widthPx}
/>
</RightAuxiliaryPane>
);
}

View File

@@ -0,0 +1,42 @@
import type * as React from "react";
import type { MarkdownDocTarget } from "@/shared/ui/markdown/markdownDocViewerContext";
import { HomeMarkdownDocPanel } from "./HomeMarkdownDocPanel";
type Props = {
besideDetail: boolean;
canResetWidth: boolean;
doc: MarkdownDocTarget | null;
onClose: () => void;
onResetWidth: () => void;
onResizeStart: (event: React.PointerEvent<HTMLButtonElement>) => void;
showDetail: boolean;
widthPx: number;
};
/** Responsive Inbox document surfaces: third pane when roomy, stack otherwise. */
export function HomeMarkdownDocSurfaces(props: Props) {
if (!props.doc) return null;
return props.besideDetail ? (
<HomeMarkdownDocPanel
canResetWidth={props.canResetWidth}
doc={props.doc}
onClose={props.onClose}
onResetWidth={props.onResetWidth}
onResizeStart={props.onResizeStart}
testId="home-markdown-doc-panel"
widthPx={props.widthPx}
/>
) : props.showDetail ? (
<HomeMarkdownDocPanel
canResetWidth={props.canResetWidth}
className="absolute inset-y-0 right-0 z-41"
doc={props.doc}
onClose={props.onClose}
onResetWidth={props.onResetWidth}
onResizeStart={props.onResizeStart}
testId="home-markdown-doc-stacked-panel"
widthPx={props.widthPx}
/>
) : null;
}

View File

@@ -1,6 +1,5 @@
import * as React from "react";
import { RefreshCcw } from "lucide-react";
import { useAppShell } from "@/app/AppShellContext";
import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys";
import { useChannelsQuery } from "@/features/channels/hooks";
@@ -27,6 +26,7 @@ import { useHomeInboxAutoSelection } from "@/features/home/useHomeInboxAutoSelec
import { useHomeInboxContextMessages } from "@/features/home/useHomeInboxContextMessages";
import { useHomePersonalInbox } from "@/features/home/useHomePersonalInbox";
import { useInboxThreadContext } from "@/features/home/useInboxThreadContext";
import { useInboxMarkdownDoc } from "@/features/home/useInboxMarkdownDoc";
import { useHiddenDmInboxNavigation } from "@/features/home/useHiddenDmInboxNavigation";
import {
type ProfilePanelTab,
@@ -47,6 +47,7 @@ import { HomeLoadingState } from "@/features/home/ui/HomeLoadingState";
import { InboxDetailPane } from "@/features/home/ui/InboxDetailPane";
import { InboxListPane } from "@/features/home/ui/InboxListPane";
import { HomePersonalInboxDetail } from "@/features/home/ui/HomePersonalInboxDetail";
import { HomeMarkdownDocSurfaces } from "@/features/home/ui/HomeMarkdownDocSurfaces";
import {
useChannelMessagesQuery,
useToggleReactionMutation,
@@ -73,14 +74,12 @@ import { useHistorySearchState } from "@/shared/hooks/useHistorySearchState";
import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext";
import { Button } from "@/shared/ui/button";
import { HomeMembersSidebarOverlay } from "./HomeMembersSidebarOverlay";
const INBOX_SEARCH_KEYS = [
"item",
"profile",
"profileTab",
"profileView",
] as const;
type HomeViewProps = {
feed?: HomeFeedResponse;
isLoading?: boolean;
@@ -94,7 +93,6 @@ type HomeViewProps = {
) => void;
onRefresh: () => void;
};
export function HomeView({
feed,
isLoading = false,
@@ -253,7 +251,6 @@ export function HomeView({
selectedEventId,
availableChannelIds,
});
const threadContextFeedItem = activeLatchedItem;
// Derive the default composer parent from the active anchor's own tags so
// that InboxDetailPane can recover the original reply target even when the
@@ -282,13 +279,6 @@ export function HomeView({
return channels.find((channel) => channel.id === managedChannelId) ?? null;
}, [channels, managedChannelId]);
const isChannelManagementOpen = managedChannel !== null;
const hasAuxiliaryPane =
isChannelManagementOpen || profilePanelPubkey !== null;
const isSinglePanelAuxiliaryView =
hasAuxiliaryPane &&
homeInboxWidthPx > 0 &&
homeInboxWidthPx < AUXILIARY_PANEL_SINGLE_COLUMN_BREAKPOINT_PX;
const channelMessagesQuery = useChannelMessagesQuery(selectedChannel);
const toggleReactionMutation = useToggleReactionMutation();
const channelMessages = channelMessagesQuery.data;
@@ -307,7 +297,6 @@ export function HomeView({
selectedChannel,
threadContext.refreshStructuralEvents,
);
const feedProfilePubkeys = React.useMemo(
() => [
...new Set([
@@ -349,13 +338,11 @@ export function HomeView({
const communityAgentPubkeys = useKnownAgentPubkeys();
const inboxAgentPubkeys = React.useMemo(() => {
const pubkeys = new Set(communityAgentPubkeys);
for (const [pubkey, profile] of Object.entries(feedProfiles ?? {})) {
if (profile.isAgent) {
pubkeys.add(normalizePubkey(pubkey));
}
}
return pubkeys;
}, [feedProfiles, communityAgentPubkeys]);
// biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion invalidates the stable getChannelReadAt callback
@@ -417,7 +404,25 @@ export function HomeView({
: null;
const selectedConversationId =
selectedItemFromAll?.conversationId ?? latchedConversationId;
const {
besideDetail: inboxDocCanFitBesideDetail,
close: closeMarkdownDoc,
doc: markdownDoc,
open: openMarkdownDoc,
} = useInboxMarkdownDoc({
conversationId: selectedConversationId,
homeWidthPx: homeInboxWidthPx,
inboxListWidthPx,
panelWidthPx: threadPanelWidthPx,
});
const hasAuxiliaryPane =
isChannelManagementOpen ||
profilePanelPubkey !== null ||
inboxDocCanFitBesideDetail;
const isSinglePanelAuxiliaryView =
hasAuxiliaryPane &&
homeInboxWidthPx > 0 &&
homeInboxWidthPx < AUXILIARY_PANEL_SINGLE_COLUMN_BREAKPOINT_PX;
const filteredItems = React.useMemo(() => {
return inboxItems.filter(
(item) =>
@@ -523,7 +528,6 @@ export function HomeView({
setAutoSelectedEventId,
urlSelectedItemId,
});
React.useEffect(() => {
void selectedConversationId;
setEmptyDeleteId(null);
@@ -531,7 +535,6 @@ export function HomeView({
setIsDeletingMessage(false);
setIsSendingReply(false);
}, [selectedConversationId]);
const handleFilterChange = React.useCallback(
(nextFilter: InboxFilter) => {
const nextItems = inboxItems.filter(
@@ -546,12 +549,10 @@ export function HomeView({
items: nextItems,
selectedConversationId,
});
setUnreadBoundary(null);
setSelectedDraftKey(null);
setSelectedReminderId(null);
setFilter(nextFilter);
if (
nextFilter === "reminders" ||
nextFilter === "drafts" ||
@@ -563,7 +564,6 @@ export function HomeView({
}
return;
}
applyInboxSearchPatch({ item: null });
setAutoSelectedEventId(selection.autoSelectedEventId);
},
@@ -579,11 +579,9 @@ export function HomeView({
unreadOnly,
],
);
if (isLoading && !feed) {
return <HomeLoadingState />;
}
if (!feed) {
return (
<div className="flex-1 overflow-hidden px-4 pb-3 pt-4 sm:px-6">
@@ -604,7 +602,6 @@ export function HomeView({
</div>
);
}
const { canDelete, canReact, canReply, disabledReplyReason } =
getHomeMessageCapabilities(
selectedItem,
@@ -642,7 +639,6 @@ export function HomeView({
selectedReminder: selectedReminder !== null,
threadPanelWidthPx,
});
return (
<ProfilePanelProvider onOpenProfilePanel={handleOpenProfilePanel}>
<DeleteMessageConfirmDialog
@@ -688,7 +684,6 @@ export function HomeView({
data-testid="home-inbox-shared-header-backdrop"
/>
) : null}
{showListPane ? (
<InboxListPane
activeReminderEventIds={activeReminderEventIds}
@@ -755,7 +750,6 @@ export function HomeView({
unreadOnly={unreadOnly}
/>
) : null}
<button
aria-label="Resize inbox list"
className={cn(
@@ -778,7 +772,6 @@ export function HomeView({
>
<span className="absolute bottom-0 left-1/2 top-0 w-px -translate-x-1/2 bg-transparent transition-colors group-hover:bg-border/80 group-focus-visible:bg-border/80" />
</button>
{showDetailPane && detailMode === "messages" ? (
<InboxDetailPane
agentPubkeys={inboxAgentPubkeys}
@@ -822,6 +815,7 @@ export function HomeView({
onEditSave={editMessage}
onRequestEmptyEditDelete={setEmptyDeleteId}
onOpenContext={handleOpenSelectedContext}
onOpenMarkdownDoc={openMarkdownDoc}
reopenPending={isReopenPending(selectedItem?.item.channelId)}
reopenErrored={isReopenErrored(selectedItem?.item.channelId)}
onSendReply={async ({
@@ -834,7 +828,6 @@ export function HomeView({
if (!selectedItem || !channelId || !canReply) {
throw new Error("Replies are not available for this item.");
}
const itemToReply = selectedItem;
setIsSendingReply(true);
try {
@@ -981,6 +974,16 @@ export function HomeView({
/>
</RightAuxiliaryPane>
) : null}
<HomeMarkdownDocSurfaces
besideDetail={inboxDocCanFitBesideDetail}
canResetWidth={canResetThreadPanelWidth}
doc={markdownDoc}
onClose={closeMarkdownDoc}
onResetWidth={handleThreadPanelWidthReset}
onResizeStart={handleThreadPanelResizeStart}
showDetail={showDetailPane}
widthPx={auxiliaryPaneWidthPx}
/>
</div>
</div>
<HomeMembersSidebarOverlay

View File

@@ -50,6 +50,10 @@ import type { Channel, UserProfileSummary } from "@/shared/api/types";
import { TopChromeInsetHeader } from "@/shared/layout/TopChromeInsetHeader";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import {
type MarkdownDocTarget,
MarkdownDocViewerProvider,
} from "@/shared/ui/markdown/markdownDocViewerContext";
import { VideoReviewNavigationProvider } from "@/shared/ui/VideoReviewNavigation";
import {
DropdownMenu,
@@ -125,6 +129,7 @@ type InboxDetailPaneProps = {
messageId: string,
threadRootId?: string | null,
) => void;
onOpenMarkdownDoc: (doc: MarkdownDocTarget) => void;
/** True while the selected hidden DM is being reopened on the relay. */
reopenPending?: boolean;
/** True when the last reopen of the selected hidden DM failed. */
@@ -157,7 +162,9 @@ export function InboxDetailPane(props: InboxDetailPaneProps) {
return (
<VideoReviewNavigationProvider>
<InboxMessageDetailPane {...props} />
<MarkdownDocViewerProvider value={props.onOpenMarkdownDoc}>
<InboxMessageDetailPane {...props} />
</MarkdownDocViewerProvider>
</VideoReviewNavigationProvider>
);
}

View File

@@ -0,0 +1,37 @@
import * as React from "react";
import type { MarkdownDocTarget } from "@/shared/ui/markdown/markdownDocViewerContext";
import { AUXILIARY_PANEL_MIN_WIDTH_PX } from "@/shared/layout/AuxiliaryPanel";
/** Owns Inbox-local document state and its responsive third-pane threshold. */
export function useInboxMarkdownDoc({
conversationId,
homeWidthPx,
inboxListWidthPx,
panelWidthPx,
}: {
conversationId: string | null;
homeWidthPx: number;
inboxListWidthPx: number;
panelWidthPx: number;
}) {
const [state, setState] = React.useState<{
conversationId: string | null;
doc: MarkdownDocTarget | null;
}>({ conversationId, doc: null });
const doc = state.conversationId === conversationId ? state.doc : null;
const open = React.useCallback(
(next: MarkdownDocTarget | null) => setState({ conversationId, doc: next }),
[conversationId],
);
const besideDetail =
doc !== null &&
homeWidthPx >=
inboxListWidthPx + panelWidthPx + AUXILIARY_PANEL_MIN_WIDTH_PX;
return {
besideDetail,
close: React.useCallback(() => open(null), [open]),
doc,
open,
};
}

View File

@@ -5,13 +5,11 @@ import { toast } from "sonner";
import { invokeTauri } from "@/shared/api/tauri";
import { useSmoothCorners } from "@/shared/ui/smoothCorners";
import { isRelayDownloadable } from "./mediaEntry";
import {
isMarkdownDocFilename,
MAX_MARKDOWN_DOC_BYTES,
} 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 {
@@ -38,10 +36,10 @@ function formatFileSize(bytes: number): string {
* 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_markdown_doc_bytes` command (native 2 MiB cap), which accepts
* relay `/media/` origins alone.
* in-app markdown viewer panel instead when a hosting surface provides one.
* The authenticated `fetch_markdown_doc_bytes` command owns the authoritative
* relay-origin check and native 2 MiB cap; duplicating its origin check here
* makes the affordance depend on asynchronous frontend cache timing.
*/
export function FileCard({
href,
@@ -56,14 +54,17 @@ export function FileCard({
const sizeLabel = size != null ? formatFileSize(size) : "";
useSmoothCorners(cardRef);
const openMarkdownDoc = useMarkdownDocViewer();
const { relayOrigin } = useMarkdownRuntime();
const isMarkdownDoc = isMarkdownDocFilename(filename);
const isWithinPreviewSize = size == null || size <= MAX_MARKDOWN_DOC_BYTES;
const opensInViewer =
openMarkdownDoc !== null &&
isMarkdownDocFilename(filename) &&
// Advertised-size pre-gate: skip fetching a doc the viewer would refuse
// anyway. The imeta size is untrusted, so the decoder re-checks post-fetch.
(size == null || size <= MAX_MARKDOWN_DOC_BYTES) &&
isRelayDownloadable(href, relayOrigin ?? undefined);
openMarkdownDoc !== null && isMarkdownDoc && isWithinPreviewSize;
const downloadReason = !isMarkdownDoc
? "not-markdown"
: !isWithinPreviewSize
? "too-large"
: openMarkdownDoc === null
? "viewer-unavailable"
: undefined;
return (
<button
@@ -86,6 +87,9 @@ export function FileCard({
// to the card that opened this URL (the element itself may have been
// unmounted and remounted in the narrow layout meanwhile).
data-doc-url={opensInViewer ? href : undefined}
data-download-reason={downloadReason}
data-filename={filename}
data-size={size}
data-testid="file-card"
className="my-1 inline-flex max-w-sm items-center gap-3 rounded-2xl border border-border/70 bg-muted/40 px-3 py-2 text-left no-underline transition-colors hover:bg-muted/70"
style={{ borderRadius: "1rem" }}

View File

@@ -84,6 +84,36 @@ async function attachAndSendMarkdown(page: Page) {
await expect(page.getByText("Sending")).toHaveCount(0);
}
test("markdown attachment sent as a thread reply keeps the viewer action", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.locator('[data-testid^="reply-message-"]').first().click({
force: true,
});
const threadPanel = page.getByTestId("message-thread-panel");
await expect(threadPanel).toBeVisible();
const [chooser] = await Promise.all([
page.waitForEvent("filechooser"),
threadPanel.getByRole("button", { name: "Attach file" }).click(),
]);
await chooser.setFiles({
buffer: Buffer.from(DOC_MARKDOWN),
mimeType: "text/markdown",
name: "release-notes.md",
});
await threadPanel.getByTestId("send-message").click();
await expect(threadPanel.getByText("Sending")).toHaveCount(0);
const card = threadPanel.getByTestId("file-card").last();
await expect(card).toContainText("release-notes.md");
await expect(card).toHaveAttribute("aria-label", "Open release-notes.md");
});
test("markdown attachment opens the in-app viewer with Preview/Code toggle", async ({
page,
}) => {
@@ -207,6 +237,12 @@ test("open document survives reload and back/forward navigation", async ({
// restores it — the advertised back/forward contract.
await page.goBack();
await expect(page.getByTestId("markdown-doc-panel")).toHaveCount(0);
// Rehydrating the channel must not downgrade a known markdown attachment to
// Download while relay-origin discovery catches up.
await expect(page.getByTestId("file-card").last()).toHaveAttribute(
"aria-label",
"Open release-notes.md",
);
await page.goForward();
await expect(
panel().getByRole("heading", { name: "Release Notes" }),