feat(desktop): add focused thread mode (#2108)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
morgmart
2026-07-19 09:55:39 -06:00
committed by GitHub
co-authored by Claude
parent 860263b1b6
commit 2d0bd6ec4a
27 changed files with 1654 additions and 324 deletions
+1
View File
@@ -74,6 +74,7 @@ export default defineConfig({
"**/boot-splash.spec.ts",
"**/thread-reply-anchor-roleplay.spec.ts",
"**/threadpane-ultrawide.spec.ts",
"**/thread-focus-mode.spec.ts",
"**/animated-avatar.spec.ts",
"**/reminders.spec.ts",
"**/reminder-click-repro.spec.ts",
@@ -1,5 +1,6 @@
import * as React from "react";
import { hasActiveEscapeSurface } from "@/shared/hooks/escapeSurfaces";
import { hasPrimaryShortcutModifier } from "@/shared/lib/platform";
export function useMarkAsReadShortcuts({
@@ -23,6 +24,13 @@ export function useMarkAsReadShortcuts({
if (event.key !== "Escape") return;
if (event.defaultPrevented) return;
if (hasPrimaryShortcutModifier(event) || event.altKey) return;
// A closable foreground surface (focus drawer, overlay panel,
// single-panel thread — anything holding `useEscapeKey`) owns Escape
// while open: Escape means "close that", never "mark the channel I
// can't currently see as read". Window listeners fire in registration
// order, so without this yield the app-mount listener would win the
// key over the surface that opened later.
if (hasActiveEscapeSurface()) return;
if (event.shiftKey) {
event.preventDefault();
@@ -0,0 +1,51 @@
/**
* Layout constants for the focus-mode thread drawer.
*
* Focus mode overlays the channel content area with a right-anchored drawer
* rather than splitting the row into two resizable panes.
*/
/**
* Width of the channel sliver left visible to the left of the focus drawer.
*
* Wide enough to read a truncated `‹ #channel` label and to be a comfortable,
* full-height click target back to the channel, but narrow enough that the
* drawer still reads as the primary surface. The sliver keeps showing the real,
* still-mounted channel timeline (dimmed by the scrim) so the user never loses
* their place.
*/
export const THREAD_FOCUS_SLIVER_WIDTH_PX = 72;
/**
* Max width of the centered message column inside the focus drawer.
*
* The drawer itself spans nearly the whole channel content area, but message
* text set that wide is unreadable. The list and composer share this max width
* with auto horizontal margins so the reading measure stays comfortable no
* matter how wide the window gets.
*/
export const THREAD_FOCUS_COLUMN_MAX_WIDTH_PX = 880;
/**
* Horizontal distance the focus drawer travels on enter/exit.
*
* Deliberately a fraction of the drawer's own width rather than a true slide
* from off-screen: opening a thread is a high-frequency act — threads are chat
* sessions and get flipped between constantly — and full-width travel turns a
* routine move into ceremony. Short travel keeps it light and repeatable.
*
* The floor matters as much as the ceiling: the shared 24px side-panel nudge is
* only ~3% of this drawer's width, which reads as no movement at all, leaving
* the opacity fade as the only perceptible change. This is large enough for the
* eye to track a direction and for the ease to have somewhere to decelerate.
*/
export const THREAD_FOCUS_DRAWER_TRAVEL_PX = 120;
/**
* `AnimatePresence` key shared by both thread layouts.
*
* The split pane and the focus drawer are two containers for one thread, so
* presence is a property of the thread, not of either container. Keying them
* apart would make every view-mode switch read as a close followed by an open.
*/
export const THREAD_SURFACE_KEY = "message-thread-surface";
@@ -0,0 +1,45 @@
import type * as React from "react";
import { THREAD_FOCUS_COLUMN_MAX_WIDTH_PX } from "@/features/channels/lib/threadFocusLayout";
export type ThreadPanelLayoutProps = {
columnMaxWidthPx?: number;
headerLeading?: React.ReactNode;
isFocusMode: boolean;
isSinglePanelView?: boolean;
layout?: "standalone" | "split";
transparentChrome?: boolean;
};
type ThreadPanelLayoutOptions = {
headerLeading?: React.ReactNode;
isFocusDrawer: boolean;
isSinglePanelView: boolean;
useSplitAuxiliaryPane: boolean;
};
/** Maps channel presentation into the shared thread-panel layout contract. */
export function getThreadPanelLayout({
headerLeading,
isFocusDrawer,
isSinglePanelView,
useSplitAuxiliaryPane,
}: ThreadPanelLayoutOptions): ThreadPanelLayoutProps {
return isFocusDrawer
? {
columnMaxWidthPx: THREAD_FOCUS_COLUMN_MAX_WIDTH_PX,
headerLeading,
isFocusMode: true,
isSinglePanelView: true,
layout: "standalone",
transparentChrome: false,
}
: {
columnMaxWidthPx: undefined,
headerLeading,
isFocusMode: false,
isSinglePanelView: useSplitAuxiliaryPane ? false : isSinglePanelView,
layout: useSplitAuxiliaryPane ? "split" : "standalone",
transparentChrome: useSplitAuxiliaryPane,
};
}
@@ -0,0 +1,80 @@
import assert from "node:assert/strict";
import test from "node:test";
const KEY = "buzz.channels.threadViewMode";
let importSequence = 0;
async function withStorage(storage, run) {
const descriptor = Object.getOwnPropertyDescriptor(
globalThis,
"localStorage",
);
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: storage,
});
try {
const module = await import(
`./threadViewModePreference.ts?test=${importSequence++}`
);
await run(module);
} finally {
if (descriptor)
Object.defineProperty(globalThis, "localStorage", descriptor);
else delete globalThis.localStorage;
}
}
test("missing, malformed, and unreadable preferences default to split", async () => {
for (const stored of [null, "side-by-side", "{bad-json"]) {
await withStorage(
{ getItem: (key) => (key === KEY ? stored : null), setItem() {} },
({ getThreadViewMode }) => {
assert.equal(getThreadViewMode(), "split");
},
);
}
await withStorage(
{
getItem() {
throw new Error("storage unavailable");
},
setItem() {},
},
({ getThreadViewMode }) => {
assert.equal(getThreadViewMode(), "split");
},
);
});
test("loads and writes the stored split preference", async () => {
const writes = [];
await withStorage(
{
getItem: (key) => (key === KEY ? "split" : null),
setItem: (key, value) => writes.push([key, value]),
},
({ getThreadViewMode, setThreadViewMode }) => {
assert.equal(getThreadViewMode(), "split");
setThreadViewMode("focus");
assert.equal(getThreadViewMode(), "focus");
assert.deepEqual(writes, [[KEY, "focus"]]);
},
);
});
test("keeps the in-memory choice when persistence fails", async () => {
await withStorage(
{
getItem: () => null,
setItem() {
throw new Error("quota exceeded");
},
},
({ getThreadViewMode, setThreadViewMode }) => {
assert.doesNotThrow(() => setThreadViewMode("split"));
assert.equal(getThreadViewMode(), "split");
},
);
});
@@ -0,0 +1,79 @@
import * as React from "react";
/**
* User preference for how a thread opens inside a channel.
*
* - `focus` — a large right-anchored drawer overlays the channel content area,
* leaving a narrow scrim-dimmed sliver of the channel visible as an
* orientation cue and a click-target back to the channel.
* - `split` — the thread opens in a resizable side panel next to the channel.
*
* Persisted in localStorage. This is a device-level UI preference, not
* community-scoped data, so it is intentionally not reset on community switch.
* Only applies at viewport widths wide enough for a two-pane channel view;
* narrow viewports keep their single-panel/floating-overlay behavior.
*/
export type ThreadViewMode = "focus" | "split";
const STORAGE_KEY = "buzz.channels.threadViewMode";
/** Layout used when nothing is stored, or the stored value is unrecognized. */
const DEFAULT_THREAD_VIEW_MODE: ThreadViewMode = "split";
const listeners = new Set<() => void>();
let threadViewMode = readStoredThreadViewMode();
function parseThreadViewMode(value: string | null | undefined): ThreadViewMode {
return value === "focus" || value === "split"
? value
: DEFAULT_THREAD_VIEW_MODE;
}
function readStoredThreadViewMode(): ThreadViewMode {
try {
return parseThreadViewMode(globalThis.localStorage?.getItem(STORAGE_KEY));
} catch {
return DEFAULT_THREAD_VIEW_MODE;
}
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
function getSnapshot(): ThreadViewMode {
return threadViewMode;
}
function getServerSnapshot(): ThreadViewMode {
return DEFAULT_THREAD_VIEW_MODE;
}
/** Read the persisted thread layout preference outside of React. */
export function getThreadViewMode(): ThreadViewMode {
return threadViewMode;
}
/** Update the thread layout preference and notify all subscribed components. */
export function setThreadViewMode(mode: ThreadViewMode): void {
threadViewMode = mode;
try {
globalThis.localStorage?.setItem(STORAGE_KEY, mode);
} catch {
// Persistence is best-effort; the in-memory value still applies.
}
for (const listener of listeners) {
listener();
}
}
/** How threads should open in a channel: as a focus drawer or a split pane. */
export function useThreadViewMode(): ThreadViewMode {
return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
+229 -171
View File
@@ -1,5 +1,6 @@
import * as React from "react";
import { Hash, LogIn } from "lucide-react";
import { AnimatePresence } from "motion/react";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useMediaUpload } from "@/features/messages/lib/useMediaUpload";
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
@@ -8,10 +9,8 @@ import { useTimeoutState } from "@/features/moderation/lib/timeoutStore";
import { isModerationDm } from "@/features/moderation/lib/moderationDm";
import { useRelaySelfQuery } from "@/features/moderation/hooks";
import { DropZoneOverlay } from "@/features/messages/ui/ComposerAttachments";
import {
MessageThreadPanel,
MessageThreadPanelSkeleton,
} from "@/features/messages/ui/MessageThreadPanel";
import { MessageThreadPanel } from "@/features/messages/ui/MessageThreadPanel";
import { MessageThreadPanelSkeleton } from "@/features/messages/ui/MessageThreadPanelSkeleton";
import {
MessageTimeline,
type MessageTimelineHandle,
@@ -32,6 +31,13 @@ import { ChannelFindBar } from "@/features/search/ui/ChannelFindBar";
import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThreadPanel";
import { ChannelManagementAuxiliaryPanel } from "@/features/channels/ui/ChannelManagementAuxiliaryPanel";
import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane";
import { ThreadViewModeToggle } from "@/features/channels/ui/ThreadViewModeToggle";
import { FocusThreadDrawer } from "@/features/channels/ui/FocusThreadDrawer";
import { THREAD_SURFACE_KEY } from "@/features/channels/lib/threadFocusLayout";
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 { useChannelWorkingAgentPubkeys } from "@/features/agents/agentWorkingSignal";
import { BotActivityComposerAction } from "@/features/channels/ui/BotActivityBar";
import {
@@ -507,6 +513,23 @@ export const ChannelPane = React.memo(function ChannelPane({
const isOverlay = useIsThreadPanelOverlay();
const useSplitAuxiliaryPane = !isSinglePanelView && !isOverlay;
const threadViewMode = useThreadViewMode();
// Focus mode is a wide-viewport-only alternative to the split thread pane:
// narrow viewports keep their existing single-panel / floating-overlay
// behavior untouched. It applies to the thread panel only — channel
// management, agent session and profile panels always use the split pane.
const useFocusThreadDrawer =
threadViewMode === "focus" &&
useSplitAuxiliaryPane &&
(Boolean(threadHeadMessage) || shouldShowThreadSkeleton);
const { channelIsCovered, markExitComplete } =
useFocusDrawerPresence(useFocusThreadDrawer);
const { changeThreadViewMode, layoutScrollTargetId, resolveScrollTarget } =
useThreadViewModeSwitch({
externalScrollTargetId: threadScrollTargetId,
onExternalTargetResolved: onThreadScrollTargetResolved,
onModeChange: markExitComplete,
});
const selectedAgent = React.useMemo(
() =>
agentSessionSelection.resolveSelectedAgentSession({
@@ -524,10 +547,15 @@ export const ChannelPane = React.memo(function ChannelPane({
shouldShowThreadSkeleton ||
Boolean(activeChannel && selectedAgent) ||
Boolean(profilePanelPubkey));
const wrapAux = (panel: React.ReactNode, testId: string) =>
const wrapAux = (
panel: React.ReactNode,
testId: string,
options: { key?: string } = {},
) =>
useSplitAuxiliaryPane ? (
<RightAuxiliaryPane
canResetWidth={canResetThreadPanelWidth}
key={options.key ?? testId}
onResetWidth={onResetThreadPanelWidth}
onResizeStart={onThreadPanelResizeStart}
testId={testId}
@@ -536,8 +564,29 @@ export const ChannelPane = React.memo(function ChannelPane({
{panel}
</RightAuxiliaryPane>
) : (
panel
<React.Fragment key={options.key ?? testId}>{panel}</React.Fragment>
);
const wrapThreadPanel = (panel: React.ReactNode) =>
useFocusThreadDrawer ? (
<FocusThreadDrawer
channelName={activeChannel?.name ?? "channel"}
key={THREAD_SURFACE_KEY}
onClose={onCloseThread}
>
{panel}
</FocusThreadDrawer>
) : (
wrapAux(panel, "message-thread-panel", { key: THREAD_SURFACE_KEY })
);
const threadHeaderLeading = useSplitAuxiliaryPane ? (
<ThreadViewModeToggle onChange={changeThreadViewMode} />
) : undefined;
const threadLayoutProps = getThreadPanelLayout({
headerLeading: threadHeaderLeading,
isFocusDrawer: useFocusThreadDrawer,
isSinglePanelView,
useSplitAuxiliaryPane,
});
return (
<div className="relative flex min-h-0 min-w-0 flex-1 flex-row overflow-hidden">
{!isSinglePanelView ? (
@@ -554,7 +603,8 @@ export const ChannelPane = React.memo(function ChannelPane({
{!isSinglePanelView ? (
<section
aria-label="Channel messages and composer"
className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"
className="relative z-0 flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"
inert={channelIsCovered ? true : undefined}
data-testid="channel-drop-zone"
onDragEnter={
canDropInMainColumn ? mainComposerMedia.handleDragEnter : undefined
@@ -646,7 +696,9 @@ export const ChannelPane = React.memo(function ChannelPane({
searchQuery={channelFind.query}
targetMessageId={targetMessageId}
splitThreadPanelOpen={
useSplitAuxiliaryPane && Boolean(openThreadHeadId)
useSplitAuxiliaryPane &&
!useFocusThreadDrawer &&
Boolean(openThreadHeadId)
}
threadUnreadCounts={threadUnreadCounts}
/>
@@ -773,169 +825,175 @@ export const ChannelPane = React.memo(function ChannelPane({
</section>
) : null}
{channelManagementOpen && activeChannel ? (
<ChannelManagementAuxiliaryPanel
activeChannel={activeChannel}
canResetThreadPanelWidth={canResetThreadPanelWidth}
currentPubkey={currentPubkey}
isSinglePanelView={isSinglePanelView}
onChannelManagementDeleted={onChannelManagementDeleted}
onCloseChannelManagement={onCloseChannelManagement}
onResetThreadPanelWidth={onResetThreadPanelWidth}
onThreadPanelResizeStart={onThreadPanelResizeStart}
threadPanelWidthPx={threadPanelWidthPx}
useSplitAuxiliaryPane={useSplitAuxiliaryPane}
transparentChrome={hasSplitAuxiliaryPane}
/>
) : threadHeadMessage ? (
(() => {
const panel = (
<MessageThreadPanel
channel={activeChannel}
channelId={activeChannel?.id ?? null}
channelName={activeChannel?.name ?? "channel"}
currentPubkey={currentPubkey}
disabled={isComposerDisabled}
editTarget={threadEditTarget}
firstUnreadReplyId={threadFirstUnreadReplyId}
huddleMemberPubkeys={huddleMemberPubkeys}
huddleMemberPubkeysPending={huddleMemberPubkeysPending}
isFollowingThread={isFollowingThread}
isMessageUnreadById={isMessageUnreadById}
isSending={isSending}
isSinglePanelView={
useSplitAuxiliaryPane ? false : isSinglePanelView
}
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
transparentChrome={useSplitAuxiliaryPane}
autoSendDraftKey={autoSendDraftKey}
onAutoSubmitComplete={handleAutoSubmitComplete}
onCancelEdit={onCancelEdit}
onCancelReply={onCancelThreadReply}
onClose={onCloseThread}
onDelete={onDelete}
onEdit={onEdit}
onEditLastOwnMessage={handleEditLastOwnThreadMessage}
onEditSave={onEditSave}
onFollowThread={onFollowThread}
onMarkUnread={onMarkUnread}
onMarkRead={onMarkRead}
onExpandReplies={onExpandThreadReplies}
onSelectReplyTarget={onSelectThreadReplyTarget}
onSend={onSendThreadReply}
onScrollTargetResolved={onThreadScrollTargetResolved}
onToggleReaction={onToggleReaction}
onUnfollowThread={onUnfollowThread}
profiles={profiles}
replyTargetMessage={threadReplyTargetMessage}
scrollTargetId={threadScrollTargetId}
threadHead={threadHeadMessage}
threadHeadVideoReviewContext={threadHeadVideoReviewContext}
widthPx={threadPanelWidthPx}
threadReplies={threadMessages}
threadRepliesPending={threadMessagesPending}
threadUnreadCount={threadUnreadCounts?.get(threadHeadMessage.id)}
threadReplyUnreadCounts={threadReplyUnreadCounts}
threadTypingPubkeys={threadTypingPubkeys}
toolbarExtraActions={
hasThreadComposerBotActivity ? (
<BotActivityComposerAction
agents={activityAgents}
channelId={activeChannel?.id ?? null}
onOpenAgentSession={onOpenAgentSession}
openAgentSessionPubkey={openAgentSessionPubkey}
profiles={profiles}
workingBotPubkeys={threadComposerBotTypingPubkeys}
variant="inline"
/>
) : null
}
/>
);
return wrapAux(panel, "message-thread-panel");
})()
) : shouldShowThreadSkeleton ? (
(() => {
const panel = (
<MessageThreadPanelSkeleton
isSinglePanelView={
useSplitAuxiliaryPane ? false : isSinglePanelView
}
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
transparentChrome={useSplitAuxiliaryPane}
onClose={onCloseThread}
widthPx={threadPanelWidthPx}
/>
);
return wrapAux(panel, "message-thread-panel");
})()
) : activeChannel && selectedAgent ? (
(() => {
// When the panel was opened from a different channel than the
// currently active one, re-scope it to the active channel so
// that both the content/header AND channel-backed actions (e.g.
// Stop current turn) operate on the same channel object.
const effectiveAgentSessionChannelId =
openAgentSessionChannelId &&
activeChannel.id !== openAgentSessionChannelId
? activeChannelId
: openAgentSessionChannelId;
const panel = (
<AgentSessionThreadPanel
agent={selectedAgent}
canInterruptTurn={selectedAgent.canInterruptTurn}
channel={
effectiveAgentSessionChannelId
? effectiveAgentSessionChannelId === activeChannel.id
? activeChannel
: null
: agentSessionSelection.isAgentInActivityList({
activityAgents,
selectedAgent,
})
? activeChannel
: null
}
channelId={effectiveAgentSessionChannelId}
isSinglePanelView={
useSplitAuxiliaryPane ? false : isSinglePanelView
}
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
transparentChrome={useSplitAuxiliaryPane}
profiles={profiles}
onBack={onBackFromAgentSession}
onClose={onCloseAgentSession}
widthPx={threadPanelWidthPx}
/>
);
return wrapAux(panel, "agent-session-thread-panel");
})()
) : profilePanelPubkey ? (
(() => {
const panel = (
<UserProfilePanel
currentPubkey={currentPubkey}
callerChannelId={activeChannelId}
isSinglePanelView={
useSplitAuxiliaryPane ? false : isSinglePanelView
}
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
transparentChrome={useSplitAuxiliaryPane}
onClose={onCloseProfilePanel}
onOpenDm={onOpenDm}
onOpenProfile={onOpenProfilePanel}
onTabChange={onProfilePanelTabChange}
onViewChange={onProfilePanelViewChange}
pubkey={profilePanelPubkey}
splitPaneClamp
tab={profilePanelTab}
view={profilePanelView}
widthPx={threadPanelWidthPx}
/>
);
return wrapAux(panel, "user-profile-panel");
})()
) : null}
{/*
* `AnimatePresence` keeps the focus thread drawer mounted through its exit
* animation — without it the drawer's own existence condition
* (`useFocusThreadDrawer`, which is derived from `threadHeadMessage`) goes
* false on the same frame as the close, and there is nothing left to
* animate. It can hold the real thread through the exit rather than a
* frozen snapshot because the panel is fully prop-driven.
*/}
<AnimatePresence onExitComplete={markExitComplete}>
{channelManagementOpen && activeChannel ? (
<ChannelManagementAuxiliaryPanel
activeChannel={activeChannel}
canResetThreadPanelWidth={canResetThreadPanelWidth}
currentPubkey={currentPubkey}
isSinglePanelView={isSinglePanelView}
key="channel-management-panel"
onChannelManagementDeleted={onChannelManagementDeleted}
onCloseChannelManagement={onCloseChannelManagement}
onResetThreadPanelWidth={onResetThreadPanelWidth}
onThreadPanelResizeStart={onThreadPanelResizeStart}
threadPanelWidthPx={threadPanelWidthPx}
useSplitAuxiliaryPane={useSplitAuxiliaryPane}
transparentChrome={hasSplitAuxiliaryPane}
/>
) : threadHeadMessage ? (
(() => {
const panel = (
<MessageThreadPanel
channel={activeChannel}
channelId={activeChannel?.id ?? null}
channelName={activeChannel?.name ?? "channel"}
currentPubkey={currentPubkey}
disabled={isComposerDisabled}
editTarget={threadEditTarget}
firstUnreadReplyId={threadFirstUnreadReplyId}
huddleMemberPubkeys={huddleMemberPubkeys}
huddleMemberPubkeysPending={huddleMemberPubkeysPending}
isFollowingThread={isFollowingThread}
isMessageUnreadById={isMessageUnreadById}
isSending={isSending}
{...threadLayoutProps}
autoSendDraftKey={autoSendDraftKey}
onAutoSubmitComplete={handleAutoSubmitComplete}
onCancelEdit={onCancelEdit}
onCancelReply={onCancelThreadReply}
onClose={onCloseThread}
onDelete={onDelete}
onEdit={onEdit}
onEditLastOwnMessage={handleEditLastOwnThreadMessage}
onEditSave={onEditSave}
onFollowThread={onFollowThread}
onMarkUnread={onMarkUnread}
onMarkRead={onMarkRead}
onExpandReplies={onExpandThreadReplies}
onSelectReplyTarget={onSelectThreadReplyTarget}
onSend={onSendThreadReply}
onScrollTargetResolved={resolveScrollTarget}
onToggleReaction={onToggleReaction}
onUnfollowThread={onUnfollowThread}
profiles={profiles}
replyTargetMessage={threadReplyTargetMessage}
scrollTargetHighlights={!layoutScrollTargetId}
scrollTargetId={layoutScrollTargetId ?? threadScrollTargetId}
threadHead={threadHeadMessage}
threadHeadVideoReviewContext={threadHeadVideoReviewContext}
widthPx={threadPanelWidthPx}
threadReplies={threadMessages}
threadRepliesPending={threadMessagesPending}
threadUnreadCount={threadUnreadCounts?.get(
threadHeadMessage.id,
)}
threadReplyUnreadCounts={threadReplyUnreadCounts}
threadTypingPubkeys={threadTypingPubkeys}
toolbarExtraActions={
hasThreadComposerBotActivity ? (
<BotActivityComposerAction
agents={activityAgents}
channelId={activeChannel?.id ?? null}
onOpenAgentSession={onOpenAgentSession}
openAgentSessionPubkey={openAgentSessionPubkey}
profiles={profiles}
workingBotPubkeys={threadComposerBotTypingPubkeys}
variant="inline"
/>
) : null
}
/>
);
return wrapThreadPanel(panel);
})()
) : shouldShowThreadSkeleton ? (
(() => {
const panel = (
<MessageThreadPanelSkeleton
{...threadLayoutProps}
onClose={onCloseThread}
widthPx={threadPanelWidthPx}
/>
);
return wrapThreadPanel(panel);
})()
) : activeChannel && selectedAgent ? (
(() => {
// When the panel was opened from a different channel than the
// currently active one, re-scope it to the active channel so
// that both the content/header AND channel-backed actions (e.g.
// Stop current turn) operate on the same channel object.
const effectiveAgentSessionChannelId =
openAgentSessionChannelId &&
activeChannel.id !== openAgentSessionChannelId
? activeChannelId
: openAgentSessionChannelId;
const panel = (
<AgentSessionThreadPanel
agent={selectedAgent}
canInterruptTurn={selectedAgent.canInterruptTurn}
channel={
effectiveAgentSessionChannelId
? effectiveAgentSessionChannelId === activeChannel.id
? activeChannel
: null
: agentSessionSelection.isAgentInActivityList({
activityAgents,
selectedAgent,
})
? activeChannel
: null
}
channelId={effectiveAgentSessionChannelId}
isSinglePanelView={
useSplitAuxiliaryPane ? false : isSinglePanelView
}
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
transparentChrome={useSplitAuxiliaryPane}
profiles={profiles}
onBack={onBackFromAgentSession}
onClose={onCloseAgentSession}
widthPx={threadPanelWidthPx}
/>
);
return wrapAux(panel, "agent-session-thread-panel");
})()
) : profilePanelPubkey ? (
(() => {
const panel = (
<UserProfilePanel
currentPubkey={currentPubkey}
callerChannelId={activeChannelId}
isSinglePanelView={
useSplitAuxiliaryPane ? false : isSinglePanelView
}
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
transparentChrome={useSplitAuxiliaryPane}
onClose={onCloseProfilePanel}
onOpenDm={onOpenDm}
onOpenProfile={onOpenProfilePanel}
onTabChange={onProfilePanelTabChange}
onViewChange={onProfilePanelViewChange}
pubkey={profilePanelPubkey}
splitPaneClamp
tab={profilePanelTab}
view={profilePanelView}
widthPx={threadPanelWidthPx}
/>
);
return wrapAux(panel, "user-profile-panel");
})()
) : null}
</AnimatePresence>
</div>
);
});
@@ -0,0 +1,224 @@
import { motion, useReducedMotion } from "motion/react";
import * as React from "react";
import {
THREAD_FOCUS_DRAWER_TRAVEL_PX,
THREAD_FOCUS_SLIVER_WIDTH_PX,
} from "@/features/channels/lib/threadFocusLayout";
import { cn } from "@/shared/lib/cn";
type FocusThreadDrawerProps = {
channelName: string;
children: React.ReactNode;
onClose: () => void;
};
/**
* Scrim over the channel content area behind the focus drawer.
*
* Veil, not shadow, and no blur: the channel fades toward the surface colour
* rather than being darkened. A black wash is a multiply — it scales text and
* background down together, so dark-on-light text keeps its contrast ratio and
* stays readable at any opacity short of a solid bar. Fading toward
* `background` instead compresses text against the surface in both themes,
* which is what pushes the sliver back to colour and shape. Matches the shared
* header backdrop's `bg-background/80` vocabulary, a touch heavier because this
* one has to defeat body text rather than sit over a gap.
*/
const FOCUS_SCRIM_CLASS = "bg-background/75 dark:bg-background/80";
/**
* Hover eases the veil one step in both themes.
*
* Feedback that the sliver is a target — deliberately not a peek: one step is
* enough to register as interactive without making the channel readable.
*/
const FOCUS_SCRIM_HOVER_CLASS =
"hover:bg-background/65 dark:hover:bg-background/70";
/** Arrive and settle. The iOS sheet curve, shared with `buzz-side-panel-enter`. */
const ENTER_EASE = [0.32, 0.72, 0, 1] as const;
/**
* Leave immediately. Shares the enter's fast-start shape rather than the
* conventional accelerating ease-in for exits.
*
* The "exits accelerate away" rule assumes the whole travel is visible; an
* ease-in spends its opening frames barely moving and pays that back at the end.
* Here the tail is hidden under the opacity fade, so acceleration buys nothing
* and those opening frames are the entire perception of responsiveness — a
* dismissal that hasn't visibly moved 40ms in reads as hesitation regardless of
* its total duration. Decisiveness comes from the duration below instead.
*/
const EXIT_EASE = ENTER_EASE;
const SCRIM_ENTER_SECONDS = 0.2;
/**
* Slightly ahead of the drawer's exit, and deliberately so.
*
* A scrim that outlasts the drawer leaves the channel dimmed with nothing on top
* of it, which reads as lag at the exact moment the user has committed to
* leaving. Undimming first hands the channel back the instant it is asked for.
*/
const SCRIM_EXIT_SECONDS = 0.12;
/**
* Enter: opacity front-loaded, transform long.
*
* The two channels animate over deliberately different windows, and that
* asymmetry is the whole point. Short travel *requires* an opacity fade — an
* opaque surface this large appearing 120px off its mark with no fade is a hard
* cut, not a slide. But pairing both properties on one timing function (as a
* single CSS keyframe must) welds them together for the full duration, and since
* opacity covers 100% of its range while transform covers ~3% of the drawer's
* width, the fade is what the eye reads. Resolving opacity in the first ~90ms
* leaves the remaining ~190ms as pure travel: the fade is over before it
* registers, and what's perceived is sliding.
*
* It also keeps the drawer's own entrance from exposing its contents' load
* order. Anything arriving late (replies resolving, media decoding) lands on an
* already-opaque surface and reads as "the thread is loading" rather than the UI
* assembling itself.
*/
const ENTER_TRANSITION = {
opacity: { duration: 0.09, ease: "linear" },
x: { duration: 0.28, ease: ENTER_EASE },
} as const;
/**
* Exit: half the enter's duration, opacity barely back-loaded.
*
* Opening and closing are not symmetric tasks. The enter has something to say —
* it establishes where the thread came from and that the channel is still behind
* it. The exit has nothing to say: attention has already left for the channel,
* so its only job is to get out of the way without popping. That makes duration
* the thing to spend, and 140ms is about the floor before the drawer reads as
* vanishing rather than leaving.
*
* The opacity hold shrinks with it. Its purpose is to let the drawer commit to
* moving before it dissolves, so it reads as sliding out — but at this duration a
* hold proportional to the old one would eat half the animation. 20ms is enough
* to register solidity in the first frame or two.
*/
const EXIT_TRANSITION = {
opacity: { delay: 0.02, duration: 0.12, ease: "linear" },
x: { duration: 0.14, ease: EXIT_EASE },
} as const;
/**
* Reduced motion keeps a crossfade and drops the travel.
*
* Travel is the part that's motion; the fade is what makes appearing and
* disappearing legible. With `x` pinned to 0 the front/back-loaded opacity
* timings would read as dead air on a stationary surface, so both collapse to
* one short symmetric fade.
*/
const REDUCED_MOTION_TRANSITION = { duration: 0.12, ease: "linear" } as const;
/**
* Right-anchored thread drawer that overlays the channel content area.
*
* Must be rendered inside `ChannelPane`'s relative layout root, and beneath an
* `AnimatePresence` so the exit animation can run: everything here is absolutely
* positioned against the channel content area, so the app sidebar is never
* covered. The channel stays mounted underneath — a narrow scrim-dimmed sliver
* of it remains visible for depth, and the whole scrim (sliver included) is one
* tall click target back to the channel. Orientation lives in the drawer
* header's breadcrumb, where the eye already is — the sliver carries no label of
* its own.
*
* `z-41` puts the overlay above the channel timeline, its `z-40` composer
* overlay and the `z-30` shared header backdrop, while staying below the global
* `z-45` top chrome. Setting z-index on the positioned container also gives the
* drawer its own stacking context, so the panel chrome inside it is isolated.
*/
export function FocusThreadDrawer({
channelName,
children,
onClose,
}: FocusThreadDrawerProps) {
const prefersReducedMotion = useReducedMotion();
const travelPx = prefersReducedMotion ? 0 : THREAD_FOCUS_DRAWER_TRAVEL_PX;
const drawerRef = React.useRef<HTMLDivElement>(null);
const previousFocusRef = React.useRef<HTMLElement | null>(null);
React.useLayoutEffect(() => {
previousFocusRef.current =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
drawerRef.current?.focus({ preventScroll: true });
return () => {
const previousFocus = previousFocusRef.current;
requestAnimationFrame(() => {
previousFocus?.focus({ preventScroll: true });
});
};
}, []);
return (
<div
className="absolute inset-0 z-41"
data-testid="focus-thread-drawer-overlay"
>
<motion.button
animate={{ opacity: 1 }}
aria-label={`Back to #${channelName}`}
className={cn(
"absolute inset-0 cursor-pointer transition-colors duration-150",
FOCUS_SCRIM_CLASS,
FOCUS_SCRIM_HOVER_CLASS,
)}
data-testid="focus-thread-drawer-scrim"
exit={{
opacity: 0,
transition: prefersReducedMotion
? REDUCED_MOTION_TRANSITION
: { duration: SCRIM_EXIT_SECONDS, ease: "linear" },
}}
initial={{ opacity: 0 }}
onClick={onClose}
transition={
prefersReducedMotion
? REDUCED_MOTION_TRANSITION
: { duration: SCRIM_ENTER_SECONDS, ease: "linear" }
}
type="button"
/>
<motion.div
animate={{ opacity: 1, x: 0 }}
className={cn(
// Left corners only, at the app content surface's own `rounded-2xl`:
// the drawer is flush to that surface's right edge, so it is *clipped*
// to its right corners rather than nesting inside them. Flush edges
// share a radius — a smaller one here would put two radii on one
// element. `shadow-panel-left` draws the left edge and its corners;
// see the token for why a `border-l` cannot.
"absolute inset-y-0 right-0 flex flex-col overflow-hidden rounded-l-2xl bg-background shadow-panel-left",
)}
aria-label="Thread"
data-testid="focus-thread-drawer"
ref={drawerRef}
role="complementary"
tabIndex={-1}
exit={{
opacity: 0,
transition: prefersReducedMotion
? REDUCED_MOTION_TRANSITION
: EXIT_TRANSITION,
x: travelPx,
}}
initial={{ opacity: 0, x: travelPx }}
style={{ left: THREAD_FOCUS_SLIVER_WIDTH_PX }}
transition={
prefersReducedMotion ? REDUCED_MOTION_TRANSITION : ENTER_TRANSITION
}
>
<div className="flex min-h-0 flex-1 flex-col">{children}</div>
</motion.div>
</div>
);
}
@@ -0,0 +1,76 @@
import { Columns2, PanelRightOpen } from "lucide-react";
import {
type ThreadViewMode,
useThreadViewMode,
} from "@/features/channels/lib/threadViewModePreference";
import { Button } from "@/shared/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
/**
* Both glyphs depict the layout the button switches *to*, never the current one.
*
* They also come from one family — each is a picture of a destination, not a verb
* — because the user watches these two alternate in the same 28px slot. A diagram
* flipping to an action icon reads as two different controls sharing a position.
*
* `columns-2` depicts the split destination, while `panel-right-open` depicts
* the thread expanding from its right-hand pane into the larger focus surface.
* The latter preserves the thread's spatial origin without implying browser
* fullscreen or a separate app window.
*/
const THREAD_VIEW_MODE_TOGGLE = {
focus: {
// Viewing the drawer → offer the pane.
icon: Columns2,
label: "Show thread beside channel",
target: "split",
},
split: {
// Viewing the pane → offer the drawer.
icon: PanelRightOpen,
label: "Expand thread",
target: "focus",
},
} as const;
/**
* Switches an open thread between the focus drawer and the split pane.
*
* Writes straight to the persisted preference, so the control doubles as the
* setting: choosing a layout here is choosing how threads open from now on. That
* is the intended behaviour — the place you form the opinion is the place you are
* looking at the thread, not a settings page — and it is why the label names an
* action rather than a state.
*
* A tooltip is mandatory, not decoration. A lone toggle showing its target is the
* conventional pattern and still routinely misread as showing the current state;
* the glyph cannot disambiguate itself, so the label has to.
*/
export function ThreadViewModeToggle({
onChange,
}: {
onChange: (mode: ThreadViewMode) => void;
}) {
const viewMode = useThreadViewMode();
const { icon: Icon, label, target } = THREAD_VIEW_MODE_TOGGLE[viewMode];
return (
<Tooltip disableHoverableContent>
<TooltipTrigger asChild>
<Button
aria-label={label}
className="shrink-0"
data-testid="thread-view-mode-toggle"
onClick={() => onChange(target)}
size="icon"
type="button"
variant="ghost"
>
<Icon />
</Button>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
}
@@ -0,0 +1,16 @@
import * as React from "react";
/** Keeps the covered channel inert until the focus drawer finishes exiting. */
export function useFocusDrawerPresence(open: boolean) {
const [present, setPresent] = React.useState(false);
React.useEffect(() => {
if (open) setPresent(true);
}, [open]);
const markExitComplete = React.useCallback(() => setPresent(false), []);
return {
channelIsCovered: open || present,
markExitComplete,
};
}
@@ -0,0 +1,56 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
findTopVisibleThreadMessageId,
getResolvedThreadTargets,
} from "./useThreadViewModeSwitch.ts";
function row(id, top, bottom) {
return {
dataset: { messageId: id },
getBoundingClientRect: () => ({ bottom, top }),
};
}
test("finds the first thread message crossing the viewport top", () => {
const rows = [
row("above", -80, -5),
row("crossing", -20, 30),
row("below", 40, 90),
];
const body = {
getBoundingClientRect: () => ({ top: 0 }),
querySelectorAll: () => rows,
};
assert.equal(findTopVisibleThreadMessageId(body), "crossing");
});
test("resolves both sources when a layout anchor matches the external target", () => {
assert.deepEqual(
getResolvedThreadTargets({
externalTargetId: "reply-b",
layoutTargetId: "reply-b",
}),
{ resolveExternal: true, resolveLayout: true },
);
assert.deepEqual(
getResolvedThreadTargets({
externalTargetId: "reply-b",
layoutTargetId: "reply-c",
}),
{ resolveExternal: false, resolveLayout: true },
);
});
test("returns null without a mounted thread body or visible message", () => {
assert.equal(findTopVisibleThreadMessageId(null), null);
assert.equal(
findTopVisibleThreadMessageId({
getBoundingClientRect: () => ({ top: 0 }),
querySelectorAll: () => [row("above", -80, -1)],
}),
null,
);
});
@@ -0,0 +1,87 @@
import * as React from "react";
import {
setThreadViewMode,
type ThreadViewMode,
} from "@/features/channels/lib/threadViewModePreference";
export function findTopVisibleThreadMessageId(
body: HTMLElement | null,
): string | null {
if (!body) return null;
const bodyTop = body.getBoundingClientRect().top;
const visibleReply = Array.from(
body.querySelectorAll<HTMLElement>("[data-message-id]"),
).find((row) => row.getBoundingClientRect().bottom > bodyTop);
return visibleReply?.dataset.messageId ?? null;
}
export function getResolvedThreadTargets({
externalTargetId,
layoutTargetId,
}: {
externalTargetId: string | null;
layoutTargetId: string | null;
}) {
return {
resolveExternal:
layoutTargetId === null || layoutTargetId === externalTargetId,
resolveLayout: layoutTargetId !== null,
};
}
type ThreadViewModeSwitchOptions = {
externalScrollTargetId: string | null;
onExternalTargetResolved: () => void;
onModeChange?: (mode: ThreadViewMode) => void;
};
/** Preserves the reply being read while the thread changes presentation. */
export function useThreadViewModeSwitch({
externalScrollTargetId,
onExternalTargetResolved,
onModeChange,
}: ThreadViewModeSwitchOptions) {
const [layoutScrollTargetId, setLayoutScrollTargetId] = React.useState<
string | null
>(null);
const changeThreadViewMode = React.useCallback(
(mode: ThreadViewMode) => {
const body = document.querySelector<HTMLElement>(
'[data-testid="message-thread-body"]',
);
const anchorId = findTopVisibleThreadMessageId(body);
setLayoutScrollTargetId(anchorId);
onModeChange?.(mode);
setThreadViewMode(mode);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
document
.querySelector<HTMLElement>(
'[data-testid="thread-view-mode-toggle"]',
)
?.focus({ preventScroll: true });
});
});
},
[onModeChange],
);
const resolveScrollTarget = React.useCallback(() => {
const resolution = getResolvedThreadTargets({
externalTargetId: externalScrollTargetId,
layoutTargetId: layoutScrollTargetId,
});
if (resolution.resolveLayout) setLayoutScrollTargetId(null);
if (resolution.resolveExternal) onExternalTargetResolved();
}, [externalScrollTargetId, layoutScrollTargetId, onExternalTargetResolved]);
return {
changeThreadViewMode,
layoutScrollTargetId,
resolveScrollTarget,
};
}
@@ -0,0 +1,18 @@
/**
* Shared inline-gutter classes for the thread panel, so the real panel and its
* loading skeleton stay pixel-aligned as content swaps in.
*/
/** Inline gutter around thread message rows. */
export const THREAD_PANEL_MESSAGE_GUTTER_CLASS = "px-2";
/** Inline gutter around the thread composer and its activity row. */
export const THREAD_PANEL_COMPOSER_GUTTER_CLASS = "px-5";
/**
* Centers the reading column when a `columnMaxWidthPx` is supplied (focus-mode
* drawer). `px-10` (40px) is the inline gutter between the column and the drawer
* edges; the max-width itself is applied inline since it is a caller-provided
* pixel value.
*/
export const THREAD_PANEL_COLUMN_CLASS = "mx-auto w-full px-10";
@@ -948,7 +948,7 @@ export function useMentions(
if (event.key === "Escape") {
event.preventDefault();
setMentionQuery(null);
cancelMentionAutocomplete(); // full cancel incl. pending debounce
return { handled: true };
}
@@ -956,6 +956,7 @@ export function useMentions(
},
[
activePersonaIds,
cancelMentionAutocomplete,
currentPubkey,
isMentionOpen,
mentionCandidatesWithTeams,
@@ -19,6 +19,7 @@ import { canManageMessageForCurrentUser } from "@/features/messages/lib/canManag
import type { TimelineMessage } from "@/features/messages/types";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { Channel } from "@/shared/api/types";
import type { ThreadPanelLayoutProps } from "@/features/channels/lib/threadPanelLayout";
import { useEscapeKey } from "@/shared/hooks/useEscapeKey";
import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile";
import { cn } from "@/shared/lib/cn";
@@ -29,10 +30,16 @@ import {
AuxiliaryPanelHeaderGroup,
AuxiliaryPanelTitle,
} from "@/shared/layout/AuxiliaryPanel";
import {
THREAD_PANEL_COLUMN_CLASS,
THREAD_PANEL_COMPOSER_GUTTER_CLASS,
THREAD_PANEL_MESSAGE_GUTTER_CLASS,
} from "@/features/messages/lib/messageThreadPanelLayout";
import { Button } from "@/shared/ui/button";
import { Skeleton } from "@/shared/ui/skeleton";
import { Separator } from "@/shared/ui/separator";
import type { VideoReviewContext } from "@/shared/ui/VideoPlayer";
import { MessageComposer } from "./MessageComposer";
import { ThreadMessageSkeleton } from "./MessageThreadPanelSkeleton";
import { MessageRow, type ThreadDepthGuideAction } from "./MessageRow";
import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
import { TypingIndicatorRow } from "./TypingIndicatorRow";
@@ -41,7 +48,7 @@ import { useComposerHeightPadding } from "./useComposerHeightPadding";
import { useAnchoredScroll } from "./useAnchoredScroll";
import { selectDeferredListRenderState } from "@/features/messages/lib/timelineSnapshot";
type MessageThreadPanelProps = {
type MessageThreadPanelProps = ThreadPanelLayoutProps & {
channel: Channel | null;
channelId: string | null;
channelName: string;
@@ -50,7 +57,6 @@ type MessageThreadPanelProps = {
firstUnreadReplyId?: string | null;
huddleMemberPubkeys?: readonly string[];
huddleMemberPubkeysPending?: boolean;
layout?: "standalone" | "split";
editTarget?: {
author: string;
body: string;
@@ -58,7 +64,6 @@ type MessageThreadPanelProps = {
imetaMedia?: ImetaMedia[];
} | null;
isSending: boolean;
isSinglePanelView?: boolean;
onCancelEdit?: () => void;
onCancelReply: () => void;
onClose: () => void;
@@ -74,6 +79,7 @@ type MessageThreadPanelProps = {
onMarkRead?: (message: TimelineMessage) => void;
onExpandReplies: (message: TimelineMessage) => void;
onScrollTargetResolved: () => void;
scrollTargetHighlights?: boolean;
onSelectReplyTarget: (message: TimelineMessage) => void;
onSend: (
content: string,
@@ -102,7 +108,6 @@ type MessageThreadPanelProps = {
threadHeadVideoReviewContext?: VideoReviewContext;
toolbarExtraActions?: React.ReactNode;
widthPx: number;
transparentChrome?: boolean;
isFollowingThread?: boolean;
isMessageUnreadById?: (messageId: string) => boolean;
onFollowThread?: () => void;
@@ -119,16 +124,7 @@ type MessageThreadPanelProps = {
};
const EMPTY_THREAD_REPLIES: MainTimelineEntry[] = [];
const THREAD_PANEL_MESSAGE_GUTTER_CLASS = "px-2";
const THREAD_PANEL_COMPOSER_GUTTER_CLASS = "px-5";
const THREAD_PANEL_SUMMARY_INDENT_OFFSET_REM = 0;
type MessageThreadPanelSkeletonProps = {
isSinglePanelView?: boolean;
layout?: "standalone" | "split";
onClose: () => void;
widthPx: number;
transparentChrome?: boolean;
};
function hasLaterVisibleSibling(
entries: readonly MainTimelineEntry[],
@@ -181,131 +177,11 @@ function getActiveContinuationDepths({
return depths;
}
function ThreadMessageSkeleton({ isHead = false }: { isHead?: boolean }) {
return (
<article className="relative flex items-start gap-2.5 rounded-2xl px-3 py-2">
<Skeleton className="h-9 w-9 shrink-0 rounded-full" />
<div className="-mt-1 min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0">
<Skeleton className="h-[15px] w-28" />
<Skeleton className="h-3 w-16" />
</div>
<div className="mt-1 space-y-1.5 pb-2">
<Skeleton className="h-4 w-full" />
<Skeleton className={isHead ? "h-4 w-4/5" : "h-4 w-2/3"} />
</div>
<div className="flex items-center gap-4">
<Skeleton className="h-4 w-8 rounded-full" />
<Skeleton className="h-4 w-8 rounded-full" />
<Skeleton className="h-4 w-8 rounded-full" />
</div>
</div>
</article>
);
}
function ThreadComposerSkeleton() {
return (
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10">
<div className="pointer-events-auto">
<div
className={cn(
"relative z-10 shrink-0 bg-transparent pb-2 pt-0",
THREAD_PANEL_COMPOSER_GUTTER_CLASS,
)}
>
<div className="relative isolate rounded-2xl border border-border/50 bg-background/80 px-3 pb-2 pt-3 shadow-none backdrop-blur-md sm:px-4">
<Skeleton className="h-5 w-48 max-w-full" />
<div className="mt-4 flex items-center gap-2">
<Skeleton className="h-8 w-8 rounded-lg" />
<Skeleton className="h-8 w-8 rounded-lg" />
<Skeleton className="ml-auto h-8 w-20 rounded-full" />
</div>
</div>
</div>
<div
className={cn(
"h-7 bg-background pb-1 pt-0",
THREAD_PANEL_COMPOSER_GUTTER_CLASS,
)}
/>
</div>
</div>
);
}
export function MessageThreadPanelSkeleton({
isSinglePanelView = false,
layout = "standalone",
onClose,
widthPx,
transparentChrome = false,
}: MessageThreadPanelSkeletonProps) {
const isOverlay = useIsThreadPanelOverlay();
useEscapeKey(onClose, isOverlay || isSinglePanelView);
const threadHeaderContent = (
<>
<AuxiliaryPanelHeaderGroup
backButtonAriaLabel="Back to conversation"
onBack={isSinglePanelView ? onClose : undefined}
>
<AuxiliaryPanelTitle>Thread</AuxiliaryPanelTitle>
</AuxiliaryPanelHeaderGroup>
</>
);
const threadBody = (
<AuxiliaryPanelBody
className="overflow-y-auto overflow-x-hidden overscroll-contain pb-24"
data-testid="message-thread-loading"
>
<div
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-1 pt-0")}
data-testid="message-thread-head-loading"
>
<ThreadMessageSkeleton isHead />
</div>
<div
className={cn(
"space-y-2.5 pb-3 pt-1",
THREAD_PANEL_MESSAGE_GUTTER_CLASS,
)}
>
<ThreadMessageSkeleton />
<ThreadMessageSkeleton />
<div className="ml-[58px] flex items-center gap-1.5 pt-0.5">
<Skeleton className="h-7 w-7 rounded-full" />
<Skeleton className="h-7 w-7 rounded-full" />
<Skeleton className="h-4 w-28 rounded-full" />
</div>
</div>
</AuxiliaryPanelBody>
);
return (
<AuxiliaryPanel
className="relative"
footer={<ThreadComposerSkeleton />}
header={
<AuxiliaryPanelHeader>{threadHeaderContent}</AuxiliaryPanelHeader>
}
isSinglePanelView={isSinglePanelView}
layout={layout}
onClose={onClose}
testId="message-thread-panel"
transparentChrome={transparentChrome}
widthPx={widthPx}
>
{threadBody}
</AuxiliaryPanel>
);
}
export function MessageThreadPanel({
channel,
channelId,
channelName,
columnMaxWidthPx,
currentPubkey,
disabled = false,
firstUnreadReplyId,
@@ -313,7 +189,9 @@ export function MessageThreadPanel({
huddleMemberPubkeysPending = false,
layout = "standalone",
editTarget,
headerLeading,
isSending,
isFocusMode,
isSinglePanelView = false,
isFollowingThread,
isMessageUnreadById,
@@ -336,6 +214,7 @@ export function MessageThreadPanel({
profiles,
replyTargetMessage,
scrollTargetId,
scrollTargetHighlights = true,
threadHead,
threadHeadVideoReviewContext,
threadReplies,
@@ -360,7 +239,8 @@ export function MessageThreadPanel({
>(null);
const isOverlay = useIsThreadPanelOverlay();
const threadHeadId = threadHead?.id ?? null;
useEscapeKey(onClose, isOverlay || isSinglePanelView);
useEscapeKey(onClose, isOverlay || isSinglePanelView || isFocusMode);
const hasConstrainedColumn = columnMaxWidthPx != null;
useComposerHeightPadding(
threadBodyRef,
threadComposerWrapperRef,
@@ -468,6 +348,13 @@ export function MessageThreadPanel({
const visibleThreadHeadSummary = isThreadHeadRepliesCollapsed
? threadHeadSummary
: null;
// Focus mode gives the thread a subject/body structure: the head is what the
// thread is about, the replies are the conversation about it. Only draw the
// rule when there is actually conversation under it — the "no replies yet"
// card and the streaming-in `pending` state would both leave a rule hanging
// over an empty region or a placeholder.
const showThreadHeadDivider =
isFocusMode && (threadRepliesPending || repliesRenderState === "list");
const threadMessages = React.useMemo(
() => deferredThreadReplies.map((entry) => entry.message),
@@ -599,6 +486,7 @@ export function MessageThreadPanel({
contentRef: threadContentRef,
isLoading: threadRepliesPending || repliesRenderState === "pending",
messages: threadMessages,
highlightTargetMessage: scrollTargetHighlights,
onTargetReached: onScrollTargetResolved,
scrollContainerRef: threadBodyRef,
targetMessageId: scrollTargetId,
@@ -640,7 +528,13 @@ export function MessageThreadPanel({
onScroll={onScroll}
ref={threadBodyRef}
>
<div ref={threadContentRef}>
<div
className={cn(hasConstrainedColumn && THREAD_PANEL_COLUMN_CLASS)}
ref={threadContentRef}
style={
hasConstrainedColumn ? { maxWidth: columnMaxWidthPx } : undefined
}
>
<div
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-1 pt-0")}
data-testid="message-thread-head"
@@ -691,6 +585,15 @@ export function MessageThreadPanel({
</div>
</div>
{showThreadHeadDivider ? (
<div
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-3 pt-2")}
data-testid="message-thread-head-divider"
>
<Separator className="bg-border/60" />
</div>
) : null}
<div
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-3 pt-0")}
data-testid="message-thread-replies"
@@ -911,7 +814,15 @@ export function MessageThreadPanel({
data-testid="thread-composer-overlay"
ref={threadComposerWrapperRef}
>
<div className="pointer-events-auto">
<div
className={cn(
"pointer-events-auto",
hasConstrainedColumn && THREAD_PANEL_COLUMN_CLASS,
)}
style={
hasConstrainedColumn ? { maxWidth: columnMaxWidthPx } : undefined
}
>
<MessageComposer
audienceContext={{
type: "thread",
@@ -974,7 +885,12 @@ export function MessageThreadPanel({
<AuxiliaryPanelHeaderGroup
backButtonAriaLabel="Back to conversation"
backButtonTestId="message-thread-back"
onBack={isSinglePanelView ? onClose : undefined}
// A focus drawer only sets `isSinglePanelView` to fill its container's
// width — it isn't the narrow single-column view, and it has the scrimmed
// sliver as its way back, so it takes no back control of its own. The
// narrow view still needs one.
leading={headerLeading}
onBack={isSinglePanelView && !isFocusMode ? onClose : undefined}
>
<AuxiliaryPanelTitle>Thread</AuxiliaryPanelTitle>
</AuxiliaryPanelHeaderGroup>
@@ -984,6 +900,8 @@ export function MessageThreadPanel({
return (
<AuxiliaryPanel
className="relative"
// The focus drawer animates itself; a second slide here would compound.
enterMotion={!isFocusMode}
footer={threadFooter}
header={
<AuxiliaryPanelHeader>{threadHeaderContent}</AuxiliaryPanelHeader>
@@ -0,0 +1,176 @@
import type { ThreadPanelLayoutProps } from "@/features/channels/lib/threadPanelLayout";
import {
THREAD_PANEL_COLUMN_CLASS,
THREAD_PANEL_COMPOSER_GUTTER_CLASS,
THREAD_PANEL_MESSAGE_GUTTER_CLASS,
} from "@/features/messages/lib/messageThreadPanelLayout";
import { useEscapeKey } from "@/shared/hooks/useEscapeKey";
import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile";
import { cn } from "@/shared/lib/cn";
import {
AuxiliaryPanel,
AuxiliaryPanelBody,
AuxiliaryPanelHeader,
AuxiliaryPanelHeaderGroup,
AuxiliaryPanelTitle,
} from "@/shared/layout/AuxiliaryPanel";
import { Skeleton } from "@/shared/ui/skeleton";
type MessageThreadPanelSkeletonProps = ThreadPanelLayoutProps & {
onClose: () => void;
widthPx: number;
};
/** Placeholder row standing in for a thread message while replies load. */
export function ThreadMessageSkeleton({
isHead = false,
}: {
isHead?: boolean;
}) {
return (
<article className="relative flex items-start gap-2.5 rounded-2xl px-3 py-2">
<Skeleton className="h-9 w-9 shrink-0 rounded-full" />
<div className="-mt-1 min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0">
<Skeleton className="h-[15px] w-28" />
<Skeleton className="h-3 w-16" />
</div>
<div className="mt-1 space-y-1.5 pb-2">
<Skeleton className="h-4 w-full" />
<Skeleton className={isHead ? "h-4 w-4/5" : "h-4 w-2/3"} />
</div>
<div className="flex items-center gap-4">
<Skeleton className="h-4 w-8 rounded-full" />
<Skeleton className="h-4 w-8 rounded-full" />
<Skeleton className="h-4 w-8 rounded-full" />
</div>
</div>
</article>
);
}
function ThreadComposerSkeleton({
columnMaxWidthPx,
}: {
columnMaxWidthPx?: number;
}) {
const hasConstrainedColumn = columnMaxWidthPx != null;
return (
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10">
<div
className={cn(
"pointer-events-auto",
hasConstrainedColumn && THREAD_PANEL_COLUMN_CLASS,
)}
style={
hasConstrainedColumn ? { maxWidth: columnMaxWidthPx } : undefined
}
>
<div
className={cn(
"relative z-10 shrink-0 bg-transparent pb-2 pt-0",
THREAD_PANEL_COMPOSER_GUTTER_CLASS,
)}
>
<div className="relative isolate rounded-2xl border border-border/50 bg-background/80 px-3 pb-2 pt-3 shadow-none backdrop-blur-md sm:px-4">
<Skeleton className="h-5 w-48 max-w-full" />
<div className="mt-4 flex items-center gap-2">
<Skeleton className="h-8 w-8 rounded-lg" />
<Skeleton className="h-8 w-8 rounded-lg" />
<Skeleton className="ml-auto h-8 w-20 rounded-full" />
</div>
</div>
</div>
<div
className={cn(
"h-7 bg-background pb-1 pt-0",
THREAD_PANEL_COMPOSER_GUTTER_CLASS,
)}
/>
</div>
</div>
);
}
/** Loading state for the thread panel, in every layout the real panel supports. */
export function MessageThreadPanelSkeleton({
columnMaxWidthPx,
headerLeading,
isFocusMode,
isSinglePanelView = false,
layout = "standalone",
onClose,
widthPx,
transparentChrome = false,
}: MessageThreadPanelSkeletonProps) {
const isOverlay = useIsThreadPanelOverlay();
const hasConstrainedColumn = columnMaxWidthPx != null;
useEscapeKey(onClose, isOverlay || isSinglePanelView || isFocusMode);
const threadHeaderContent = (
<AuxiliaryPanelHeaderGroup
backButtonAriaLabel="Back to conversation"
// Matches the loaded panel's header so it doesn't shift on resolve.
leading={headerLeading}
onBack={isSinglePanelView && !isFocusMode ? onClose : undefined}
>
<AuxiliaryPanelTitle>Thread</AuxiliaryPanelTitle>
</AuxiliaryPanelHeaderGroup>
);
const threadBody = (
<AuxiliaryPanelBody
className="overflow-y-auto overflow-x-hidden overscroll-contain pb-24"
data-testid="message-thread-loading"
>
<div
className={cn(hasConstrainedColumn && THREAD_PANEL_COLUMN_CLASS)}
style={
hasConstrainedColumn ? { maxWidth: columnMaxWidthPx } : undefined
}
>
<div
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-1 pt-0")}
data-testid="message-thread-head-loading"
>
<ThreadMessageSkeleton isHead />
</div>
<div
className={cn(
"space-y-2.5 pb-3 pt-1",
THREAD_PANEL_MESSAGE_GUTTER_CLASS,
)}
>
<ThreadMessageSkeleton />
<ThreadMessageSkeleton />
<div className="ml-[58px] flex items-center gap-1.5 pt-0.5">
<Skeleton className="h-7 w-7 rounded-full" />
<Skeleton className="h-7 w-7 rounded-full" />
<Skeleton className="h-4 w-28 rounded-full" />
</div>
</div>
</div>
</AuxiliaryPanelBody>
);
return (
<AuxiliaryPanel
className="relative"
// See `MessageThreadPanel`: the focus drawer owns the slide.
enterMotion={!isFocusMode}
footer={<ThreadComposerSkeleton columnMaxWidthPx={columnMaxWidthPx} />}
header={
<AuxiliaryPanelHeader>{threadHeaderContent}</AuxiliaryPanelHeader>
}
isSinglePanelView={isSinglePanelView}
layout={layout}
onClose={onClose}
testId="message-thread-panel"
transparentChrome={transparentChrome}
widthPx={widthPx}
>
{threadBody}
</AuxiliaryPanel>
);
}
@@ -313,7 +313,13 @@ test("auto-pins to bottom once loading clears, against content that already comm
}),
);
});
// Flush the rAF-deferred scrollToBottomImperative.
assert.equal(
refs.container.current.scrollTop,
refs.container.current.scrollHeight,
"the first bottom pin happens in the layout effect before the next frame",
);
// Flush the rAF settling pass. Late measurements must preserve the same floor.
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
@@ -321,7 +327,7 @@ test("auto-pins to bottom once loading clears, against content that already comm
assert.equal(
refs.container.current.scrollTop,
refs.container.current.scrollHeight,
"pin fires against the DOM as it exists when loading clears, not a stale mount snapshot",
"the settling pass keeps the view pinned against the committed DOM",
);
await act(async () => {
@@ -102,8 +102,10 @@ type UseAnchoredScrollOptions = {
messages: Array<{ id: string }>;
splitPanelOpen?: boolean;
/** When set, scroll to and highlight this message on mount and on change. */
/** When set, scroll to this message on mount and on change. */
targetMessageId?: string | null;
/** Whether a targeted message should pulse after scrolling to it. */
highlightTargetMessage?: boolean;
/** Keeps a targeted message centered until the user deliberately scrolls. */
pinTargetCentered?: boolean;
onTargetReached?: (messageId: string) => void;
@@ -224,6 +226,7 @@ export function useAnchoredScroll({
splitPanelOpen = false,
targetMessageId = null,
highlightTargetMessage = true,
pinTargetCentered = false,
onTargetReached,
virtualScrollToMessage,
@@ -604,10 +607,13 @@ export function useAnchoredScroll({
// to the requested target message, or to the bottom by default.
if (!hasInitializedRef.current) {
if (isLoading) return;
// Defer the scroll out of the layout effect so the current paint commits
// first; cancelled on channel switch via the reset effect's rAF guard.
// Establish the initial position before the browser paints. The follow-up
// frame is a settling pass for content whose measurements land with the
// commit (fonts, deferred rows, media), not the first bottom pin. Keeping
// both writes in the shared scroll owner gives every conversation surface
// the same first-frame behavior regardless of its surrounding animation.
const pinToBottomOnMount = () => {
anchorRef.current = { kind: "at-bottom" };
scrollToBottomImperative("auto");
mountPinRafIdRef.current = requestAnimationFrame(() => {
mountPinRafIdRef.current = null;
scrollToBottomImperative("auto");
@@ -619,7 +625,11 @@ export function useAnchoredScroll({
// render or two later. If centering fails now, leave the timeline at
// its default position and let the post-mount target effect (keyed on
// `messages`) retry once the row lands, rather than marking it handled.
if (scrollToMessageImperative(targetMessageId, { highlight: true })) {
if (
scrollToMessageImperative(targetMessageId, {
highlight: highlightTargetMessage,
})
) {
handledTargetIdRef.current = targetMessageId;
onTargetReached?.(targetMessageId);
} else {
@@ -726,6 +736,7 @@ export function useAnchoredScroll({
prevMessageCountRef.current = messages.length;
prevMessagesRef.current = messages;
}, [
highlightTargetMessage,
isLoading,
messages,
onTargetReached,
@@ -831,7 +842,11 @@ export function useAnchoredScroll({
`[data-message-id="${targetMessageId}"]`,
);
if (!el && virtualizerOwnsPrependAnchoring) {
if (scrollToMessageImperative(targetMessageId, { highlight: true })) {
if (
scrollToMessageImperative(targetMessageId, {
highlight: highlightTargetMessage,
})
) {
handledTargetIdRef.current = targetMessageId;
onTargetReached?.(targetMessageId);
}
@@ -844,9 +859,12 @@ export function useAnchoredScroll({
return;
}
handledTargetIdRef.current = targetMessageId;
scrollToMessageImperative(targetMessageId, { highlight: true });
scrollToMessageImperative(targetMessageId, {
highlight: highlightTargetMessage,
});
onTargetReached?.(targetMessageId);
}, [
highlightTargetMessage,
isLoading,
messages,
onTargetReached,
@@ -5,6 +5,7 @@ import {
BellRing,
Bot,
Check,
ChevronDown,
Cpu,
Download,
FlaskConical,
@@ -31,7 +32,20 @@ import type { SoundName, SoundSlot } from "@/features/notifications/lib/sound";
import { CommunityMembersSettingsCard } from "@/features/community-members/ui/CommunityMembersSettingsCard";
import { CustomEmojiSettingsCard } from "@/features/custom-emoji/ui/CustomEmojiSettingsCard";
import { LocalArchiveSettingsCard } from "@/features/local-archive/ui/LocalArchiveSettingsCard";
import {
setThreadViewMode,
useThreadViewMode,
type ThreadViewMode,
} from "@/features/channels/lib/threadViewModePreference";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import {
ACCENT_COLORS,
isBuzzTheme,
@@ -66,6 +80,7 @@ import { NotificationSettingsCard } from "./NotificationSettingsCard";
import { PreventSleepSettingsCard } from "./PreventSleepSettingsCard";
import { GlobalAgentConfigSettingsCard } from "./GlobalAgentConfigSettingsCard";
import { HostedCommunitiesSettingsCard } from "./HostedCommunitiesSettingsCard";
import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup";
import { ProfileSettingsCard } from "./ProfileSettingsCard";
import { UpdateChecker } from "../UpdateChecker";
import { SettingsSectionHeader } from "./SettingsSectionHeader";
@@ -639,10 +654,92 @@ function ThemeSettingsCard() {
)}
</AnimatePresence>
)}
<ThreadLayoutSetting />
</section>
);
}
const THREAD_VIEW_MODE_OPTIONS: {
value: ThreadViewMode;
label: string;
description: string;
}[] = [
{
value: "focus",
label: "Focus",
description: "Threads open over the channel, full width",
},
{
value: "split",
label: "Split",
description: "Threads open in a side panel next to the channel",
},
];
/**
* Thread layout picker. Uses the same dropdown radio group vocabulary as the
* other enumerated Settings rows (e.g. {@link SoundPicker}) so each option can
* carry its own description.
*/
function ThreadLayoutSetting() {
const threadViewMode = useThreadViewMode();
const activeOption =
THREAD_VIEW_MODE_OPTIONS.find(
(option) => option.value === threadViewMode,
) ?? THREAD_VIEW_MODE_OPTIONS[0];
return (
<SettingsOptionGroup className="mt-8">
<SettingsOptionRow>
<div className="min-w-0">
<p className="text-sm font-medium">Thread layout</p>
<p className="text-sm font-normal text-muted-foreground">
{activeOption.description}
</p>
</div>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
className="h-7 min-w-28 justify-between gap-1.5 rounded-full border border-border/50 bg-muted/45 px-2.5 text-xs font-medium text-foreground shadow-none hover:bg-muted/70"
data-testid="thread-layout-trigger"
size="sm"
type="button"
variant="ghost"
>
<span className="truncate">{activeOption.label}</span>
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-72">
<DropdownMenuRadioGroup
onValueChange={(next) =>
setThreadViewMode(next as ThreadViewMode)
}
value={threadViewMode}
>
{THREAD_VIEW_MODE_OPTIONS.map((option) => (
<DropdownMenuRadioItem
data-testid={`thread-layout-${option.value}`}
key={option.value}
value={option.value}
>
<span className="flex min-w-0 flex-col">
<span className="font-medium">{option.label}</span>
<span className="text-2xs text-muted-foreground">
{option.description}
</span>
</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</SettingsOptionRow>
</SettingsOptionGroup>
);
}
/** Accent swatch grid — shared by the animated and reduced-motion reveal paths. */
function AccentPickerContent({
accentColor,
@@ -0,0 +1,43 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
acquireEscapeSurface,
hasActiveEscapeSurface,
} from "./escapeSurfaces.ts";
test("no surfaces means background shortcuts may act", () => {
assert.equal(hasActiveEscapeSurface(), false);
});
test("acquire and release track open surfaces", () => {
const releaseA = acquireEscapeSurface();
assert.equal(hasActiveEscapeSurface(), true);
const releaseB = acquireEscapeSurface();
releaseA();
assert.equal(
hasActiveEscapeSurface(),
true,
"one surface closing must not release the other's claim",
);
releaseB();
assert.equal(hasActiveEscapeSurface(), false);
});
test("double release cannot corrupt the count", () => {
const releaseA = acquireEscapeSurface();
releaseA();
releaseA();
assert.equal(hasActiveEscapeSurface(), false);
const releaseB = acquireEscapeSurface();
assert.equal(
hasActiveEscapeSurface(),
true,
"a leaked double-release must not mask a genuinely open surface",
);
releaseB();
assert.equal(hasActiveEscapeSurface(), false);
});
@@ -0,0 +1,34 @@
/**
* Tracks closable foreground surfaces currently listening for Escape.
*
* Escape has app-wide meaning (mark channel read) *and* surface-local meaning
* (close the panel above the channel). Window listeners fire in registration
* order, so the app-level shortcut — registered at mount — would otherwise
* always win the key over a panel that opened later. Instead of racing,
* background shortcuts ask "is any closable surface open?" and yield.
*
* Nested controls (autocomplete, edit mode) still take priority over the
* surfaces themselves: they handle Escape on the element and mark it
* `defaultPrevented`, which every surface listener already respects.
*/
let activeEscapeSurfaceCount = 0;
/** True while at least one closable surface is listening for Escape. */
export function hasActiveEscapeSurface(): boolean {
return activeEscapeSurfaceCount > 0;
}
/**
* Registers a closable surface. Returns a release function that must be
* called exactly once when the surface stops listening (idempotent — extra
* calls are ignored so a double-cleanup cannot corrupt the count).
*/
export function acquireEscapeSurface(): () => void {
activeEscapeSurfaceCount += 1;
let released = false;
return () => {
if (released) return;
released = true;
activeEscapeSurfaceCount -= 1;
};
}
+13 -2
View File
@@ -1,14 +1,22 @@
import * as React from "react";
import { acquireEscapeSurface } from "@/shared/hooks/escapeSurfaces";
/**
* Calls `onEscape` when the Escape key is pressed, unless the event
* was already handled (`defaultPrevented`).
* was already handled (`defaultPrevented`) — so nested controls
* (autocomplete, edit mode) that claim Escape on the element always win.
*
* While enabled, the surface is registered with `escapeSurfaces` so
* app-level Escape shortcuts (mark channel read) know to yield instead
* of racing this listener on registration order.
*
* Pass `enabled: false` to skip registering the listener entirely.
*/
export function useEscapeKey(onEscape: () => void, enabled: boolean = true) {
React.useEffect(() => {
if (!enabled) return;
const releaseSurface = acquireEscapeSurface();
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape" && !event.defaultPrevented) {
event.preventDefault();
@@ -16,6 +24,9 @@ export function useEscapeKey(onEscape: () => void, enabled: boolean = true) {
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
releaseSurface();
};
}, [enabled, onEscape]);
}
@@ -30,6 +30,14 @@ type AuxiliaryPanelHeaderGroupProps = Omit<
align?: "center" | "start";
backButtonAriaLabel?: string;
backButtonTestId?: string;
/**
* Panel-specific control rendered ahead of the title, after any back button.
*
* A slot rather than a header-level feature because this header is shared by
* every auxiliary panel and the controls that belong here are not: only the
* thread has view modes. Each panel passes what it owns.
*/
leading?: React.ReactNode;
mode?: AuxiliaryPanelMode;
onBack?: () => void;
};
@@ -248,6 +256,7 @@ export function AuxiliaryPanelHeaderGroup({
align = "center",
backButtonAriaLabel = "Back",
backButtonTestId,
leading,
mode,
children,
onBack,
@@ -281,6 +290,15 @@ export function AuxiliaryPanelHeaderGroup({
<ArrowLeft />
</Button>
) : null}
{/*
* Sits inside the header's normal inset rather than pulled flush like the
* back button: a back button is chrome and belongs on the panel edge, but
* this is a control acting on the panel's own content, so it reads as part
* of the title row and lets the title shift right off it.
*/}
{leading ? (
<div className="flex shrink-0 items-center">{leading}</div>
) : null}
{children}
</div>
);
@@ -10,6 +10,7 @@ import { getAuxiliaryPanelMode } from "@/shared/layout/AuxiliaryPanelHeader";
import { cn } from "@/shared/lib/cn";
import {
OverlayPanelBackdrop,
PANEL_BASE_CLASS,
PANEL_ENTER_BASE_CLASS,
PANEL_OVERLAY_CLASS,
} from "@/shared/ui/OverlayPanelBackdrop";
@@ -24,6 +25,13 @@ type AuxiliaryPanelProps = {
canResetWidth?: boolean;
children: React.ReactNode;
className?: string;
/**
* When false, the panel skips its own slide-in animation.
*
* For panels rendered inside a container that already animates itself (the
* focus-mode thread drawer), so the two don't compound into a double slide.
*/
enterMotion?: boolean;
footer?: React.ReactNode;
header?: React.ReactNode;
isSinglePanelView?: boolean;
@@ -46,6 +54,7 @@ export function AuxiliaryPanel({
canResetWidth,
children,
className,
enterMotion = true,
footer,
header,
isSinglePanelView = false,
@@ -137,7 +146,7 @@ export function AuxiliaryPanel({
{isFloatingOverlay ? <OverlayPanelBackdrop onClose={onClose} /> : null}
<aside
className={cn(
PANEL_ENTER_BASE_CLASS,
enterMotion ? PANEL_ENTER_BASE_CLASS : PANEL_BASE_CLASS,
isSinglePanelView && "border-l-0",
isFloatingOverlay && PANEL_OVERLAY_CLASS,
className,
+10
View File
@@ -18,6 +18,16 @@ export default {
},
boxShadow: {
"content-edge": "-1px -1px 0 0 hsl(var(--sidebar-border) / 0.45)",
// Edge + elevation for a surface anchored to the right of the content
// area, whose only exposed edge faces left. Tailwind's stock shadows are
// all y-offset, so they cast almost nothing sideways — `shadow-xl` on a
// left-facing edge is nearly invisible. Both layers run -x so they wrap
// the surface's rounded left corners: the hairline draws the boundary
// (and carries dark mode, where a black shadow reads as nothing), the
// soft layer carries the lift. A left-only `border` can't do this job —
// it tapers out at each corner instead of turning it.
"panel-left":
"-1px 0 0 0 hsl(var(--border) / 0.8), -16px 0 32px -12px rgb(0 0 0 / 0.18)",
},
borderRadius: {
lg: "var(--radius)",
+159
View File
@@ -0,0 +1,159 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
async function seedLongThread(page: import("@playwright/test").Page) {
await expect
.poll(() =>
page.evaluate(
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
),
)
.toBe(true);
return page.evaluate(() => {
const root = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: "Focus mode integration thread",
createdAt: 1_700_900_000,
});
if (!root) throw new Error("Failed to seed focus thread root");
for (let index = 0; index < 48; index += 1) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: `Focus reply ${index}: this deliberately wraps across several lines so changing the thread measure causes real layout reflow.`,
parentEventId: root.id,
createdAt: 1_700_900_001 + index,
});
}
return root.id;
});
}
async function topVisibleMessageId(
body: import("@playwright/test").Locator,
): Promise<string> {
return body.evaluate((element) => {
const top = element.getBoundingClientRect().top;
const row = Array.from(
element.querySelectorAll<HTMLElement>("[data-message-id]"),
).find((candidate) => candidate.getBoundingClientRect().bottom > top);
if (!row?.dataset.messageId) throw new Error("No visible thread anchor");
return row.dataset.messageId;
});
}
test("focus and split preserve reading context and interaction ownership", async ({
page,
}) => {
await page.setViewportSize({ width: 1280, height: 720 });
await page.addInitScript(() => {
localStorage.setItem("buzz.channels.threadViewMode", "focus");
});
await installMockBridge(page);
await page.goto("/");
const rootId = await seedLongThread(page);
await page.getByTestId("channel-general").click();
const summary = page.locator(
`[data-testid="message-thread-summary"][data-thread-head-id="${rootId}"]`,
);
await expect(summary).toBeVisible();
await summary.click();
const channel = page.getByTestId("channel-drop-zone");
const drawer = page.getByTestId("focus-thread-drawer");
const body = page.getByTestId("message-thread-body");
await expect(drawer).toBeVisible();
await expect
.poll(() =>
page.evaluate(() =>
Boolean(
document
.querySelector('[data-testid="focus-thread-drawer"]')
?.contains(document.activeElement),
),
),
)
.toBe(true);
await expect(channel).toHaveAttribute("inert", "");
await body.evaluate((element) => {
element.scrollTop = element.scrollHeight * 0.4;
element.dispatchEvent(new Event("scroll", { bubbles: true }));
});
const anchorId = await topVisibleMessageId(body);
await page
.getByRole("button", { name: "Show thread beside channel" })
.click();
await expect(drawer).toHaveCount(0);
await expect(channel).not.toHaveAttribute("inert", "");
await expect(page.getByTestId("thread-view-mode-toggle")).toBeFocused();
await expect(
body.locator(`[data-message-id="${anchorId}"]`),
).toBeInViewport();
await expect(
body.locator(`[data-message-id="${anchorId}"]`),
).not.toHaveAttribute("data-highlighted", "true");
await page.getByRole("button", { name: "Expand thread" }).click();
await expect(drawer).toBeVisible();
await expect(channel).toHaveAttribute("inert", "");
await expect(page.getByTestId("thread-view-mode-toggle")).toBeFocused();
await expect(
body.locator(`[data-message-id="${anchorId}"]`),
).toBeInViewport();
// Escape layering: a nested control inside the drawer claims Escape first.
// With mention autocomplete open, Escape closes only the autocomplete — the
// drawer must stay.
const threadInput = page
.getByTestId("message-thread-panel")
.getByTestId("message-input");
await threadInput.click();
await threadInput.pressSequentially("@al");
const mentionDropdown = page
.getByTestId("message-thread-panel")
.getByTestId("mention-autocomplete");
await expect(mentionDropdown).toBeVisible();
await page.keyboard.press("Escape");
await expect(mentionDropdown).toHaveCount(0);
await expect(drawer).toBeVisible();
// Known gap (pre-existing, tracked as a follow-up): while the composer's
// rich-text editor holds focus it claims Escape internally even with no
// autocomplete open, so Escape-from-composer never reaches the drawer's
// close listener. Move focus to the drawer itself before asserting
// Escape-to-close. When the editor is taught to release an idle Escape,
// this focus hop can be removed.
await drawer.focus();
await page.keyboard.press("Escape");
await expect(page.getByTestId("focus-thread-drawer-overlay")).toHaveCount(0);
await expect(channel).not.toHaveAttribute("inert", "");
await summary.click();
await expect(drawer).toBeVisible();
await page.getByTestId("focus-thread-drawer-scrim").click({
position: { x: 24, y: 200 },
});
await expect(page.getByTestId("focus-thread-drawer-overlay")).toHaveCount(0);
await expect(channel).not.toHaveAttribute("inert", "");
});
test("narrow threads do not offer an unavailable layout switch", async ({
page,
}) => {
await page.setViewportSize({ width: 860, height: 720 });
await installMockBridge(page);
await page.goto("/");
const rootId = await seedLongThread(page);
await page.getByTestId("channel-general").click();
const summary = page.locator(
`[data-testid="message-thread-summary"][data-thread-head-id="${rootId}"]`,
);
await expect(summary).toBeVisible();
await summary.click();
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
await expect(page.getByTestId("thread-view-mode-toggle")).toHaveCount(0);
});
+32 -1
View File
@@ -41,11 +41,23 @@ const { values: args } = parseArgs({
viewport: { type: "string", default: "1280x720" },
outdir: { type: "string", default: "test-results/screenshots" },
messages: { type: "string" },
"local-storage": { type: "string", multiple: true, default: [] },
"update-ready": { type: "boolean", default: false },
},
strict: true,
});
// `--local-storage key=value` (repeatable) seeds a localStorage entry before the
// app mounts, so device-level preferences (thread layout, transcript options, …)
// can be captured in their non-default state.
const localStorageSeeds = args["local-storage"].map((entry) => {
const separatorIndex = entry.indexOf("=");
if (separatorIndex < 1) {
throw new Error(`--local-storage expects key=value, received "${entry}"`);
}
return [entry.slice(0, separatorIndex), entry.slice(separatorIndex + 1)];
});
const activeChannel = args["active-channel"];
const rightClick = args["right-click"];
@@ -80,7 +92,16 @@ const TEST_PUBKEYS = [
"df8e91b86fda13a9a67896df77232f7bdab2ba9c3e165378e1ba3d24c13a328e",
];
const browser = await chromium.launch({ headless: true });
// `BUZZ_HEADED=1` runs a real browser window instead of headless.
//
// Headless Chromium rasterizes in software (SwiftShader), which mis-renders
// `backdrop-filter` when an ancestor establishes a rounded clip
// (`overflow-hidden` + `border-radius`) — the blurred surface paints as solid
// cyan garbage. The app's blurred chrome (panel headers, composers) hits this
// inside the rounded focus drawer. Headed rendering is correct, as is the real
// app's WKWebView, so this is a capture-only artifact. Default stays headless so
// CI is unaffected.
const browser = await chromium.launch({ headless: !process.env.BUZZ_HEADED });
const page = await browser.newPage({
viewport: { width: vpWidth, height: vpHeight },
});
@@ -108,6 +129,16 @@ await page.addInitScript(
{ prefix: ONBOARDING_PREFIX, pubkeys: TEST_PUBKEYS },
);
// Seed caller-supplied preferences. Must run before the bridge init script:
// React reads persisted state on mount, and the bridge triggers mount.
if (localStorageSeeds.length > 0) {
await page.addInitScript((seeds) => {
for (const [key, value] of seeds) {
window.localStorage.setItem(key, value);
}
}, localStorageSeeds);
}
// Install E2E mock bridge config + MockNotification (mirrors installBridge in bridge.ts)
await page.addInitScript(
({ updateReady }) => {