mirror of
https://github.com/block/buzz.git
synced 2026-10-29 05:21:31 +00:00
fix(desktop): make markdown viewer context-aware
Signed-off-by: Clay Delk <clay.delk@gmail.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -833,6 +833,7 @@ export function ChannelScreen({
|
||||
<GuardedChannelPane
|
||||
onOpenMarkdownDoc={handleOpenMarkdownDoc}
|
||||
activeChannel={activeChannel}
|
||||
channelContentWidthPx={channelContentWidthPx}
|
||||
activityAgents={channelAgentSessionAgents}
|
||||
agentPubkeys={agentPubkeys}
|
||||
agentPubkeysPending={agentPubkeysPending}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
48
desktop/src/features/home/ui/HomeMarkdownDocPanel.tsx
Normal file
48
desktop/src/features/home/ui/HomeMarkdownDocPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
desktop/src/features/home/ui/HomeMarkdownDocSurfaces.tsx
Normal file
42
desktop/src/features/home/ui/HomeMarkdownDocSurfaces.tsx
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
37
desktop/src/features/home/useInboxMarkdownDoc.ts
Normal file
37
desktop/src/features/home/useInboxMarkdownDoc.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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" }}
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
Reference in New Issue
Block a user