mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Chat stream polish, PR work panel, and branch e2e fixes
Chat stream: - Activity markers span the full conversation column (matching the "Thought for Xs" row) instead of capping at 42rem. - Own bubbles drop the redundant "You" header. PR work panel: when the chat's agent posts a pull request, a module docks top-right inside the chat area (conversation and composer shrink to make room) showing the PR's source branch and the live PR card. fetch_github_pull_request now returns head.ref for the branch chip. Branch e2e repairs (both regressions pre-dated today): - "Jump to latest" now re-asserts the bottom until row re-measurement settles — a smooth jump aimed at a stale scrollHeight landed short once grouped rows changed height estimates. - Sidebar dnd gains a keyboard sensor with keyboard-aware collision detection, and channel-assignment drop zones are disabled while a section drag is active. The dnd e2e uses a viewport tall enough that auto-scroll can't move the drop target mid-drag. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
ecaa8573d5
commit
f19c35e025
@@ -23,6 +23,8 @@ pub struct GithubPullRequestInfo {
|
||||
pub additions: i64,
|
||||
pub deletions: i64,
|
||||
pub changed_files: i64,
|
||||
/// Source branch of the PR (`head.ref`).
|
||||
pub head_ref: String,
|
||||
}
|
||||
|
||||
/// Fetch live PR details from the GitHub REST API.
|
||||
@@ -80,6 +82,7 @@ pub async fn fetch_github_pull_request(
|
||||
additions: body["additions"].as_i64().unwrap_or(0),
|
||||
deletions: body["deletions"].as_i64().unwrap_or(0),
|
||||
changed_files: body["changed_files"].as_i64().unwrap_or(0),
|
||||
head_ref: body["head"]["ref"].as_str().unwrap_or_default().to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ export function ActivityMarkerRow({
|
||||
className={cn("py-1.5", entrance && "buzz-message-entrance")}
|
||||
side="left"
|
||||
>
|
||||
<MessageContent className="max-w-[min(42rem,78%)]">
|
||||
<MessageContent className="w-full max-w-full">
|
||||
{details ? (
|
||||
<details className="group/activity-marker" title={title}>
|
||||
<summary className="list-none">
|
||||
|
||||
@@ -480,11 +480,9 @@ function ChatTranscriptMessageRow({
|
||||
</MessageAvatar>
|
||||
) : null}
|
||||
<MessageContent className={isUser ? "items-end" : "w-full max-w-full"}>
|
||||
{!hideIdentity ? (
|
||||
<MessageHeader className={isUser ? "justify-end" : undefined}>
|
||||
<span className="truncate font-medium">
|
||||
{isUser ? "You" : label}
|
||||
</span>
|
||||
{!hideIdentity && !isUser ? (
|
||||
<MessageHeader>
|
||||
<span className="truncate font-medium">{label}</span>
|
||||
</MessageHeader>
|
||||
) : null}
|
||||
{isUser ? (
|
||||
|
||||
@@ -73,11 +73,12 @@ export function ChatMessageRow({
|
||||
<MessageContent
|
||||
className={cn(isOwn && "items-end", isAgent && "w-full max-w-full")}
|
||||
>
|
||||
{!hideIdentity ? (
|
||||
<MessageHeader className={isOwn ? "justify-end" : undefined}>
|
||||
<span className="truncate font-medium">
|
||||
{isOwn ? "You" : displayName}
|
||||
</span>
|
||||
{/* Own bubbles need no "You" header — the right-aligned bubble is
|
||||
self-explanatory. Other authors keep their name (unless the solo
|
||||
chat hides the lone agent's identity). */}
|
||||
{!hideIdentity && !isOwn ? (
|
||||
<MessageHeader>
|
||||
<span className="truncate font-medium">{displayName}</span>
|
||||
</MessageHeader>
|
||||
) : null}
|
||||
{isAgent ? (
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
NO_PROJECT_SELECTION_ID,
|
||||
} from "@/features/chats/lib/chatSetup";
|
||||
import { ChatActivityTranscript } from "@/features/chats/ui/ChatActivityTranscript";
|
||||
import { ChatWorkPanel } from "@/features/chats/ui/ChatWorkPanel";
|
||||
import { isHumanFacingAssistantText } from "@/features/chats/ui/chatActivityText";
|
||||
import { entranceClassForCreatedAt } from "@/features/chats/ui/messageEntrance";
|
||||
import {
|
||||
@@ -46,6 +47,7 @@ import type {
|
||||
} from "@/shared/api/types";
|
||||
import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { extractSupportedLinkPreviews } from "@/shared/lib/linkPreview";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import {
|
||||
MessageScroller,
|
||||
@@ -247,6 +249,28 @@ export function ChatDetail({
|
||||
);
|
||||
const hasTranscriptActivity = chatActivity.totalBlockCount > 0;
|
||||
|
||||
// The latest PR link the agent posted in this chat drives the top-right
|
||||
// work module (branch + live PR card).
|
||||
const agentPullRequestHref = React.useMemo(() => {
|
||||
if (!defaultAgent?.pubkey) {
|
||||
return null;
|
||||
}
|
||||
const agentKey = normalizePubkey(defaultAgent.pubkey);
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
const message = messages[index];
|
||||
if (normalizePubkey(message.pubkey) !== agentKey) {
|
||||
continue;
|
||||
}
|
||||
const preview = extractSupportedLinkPreviews(message.content).find(
|
||||
(candidate) => candidate.kind === "github-pull-request",
|
||||
);
|
||||
if (preview) {
|
||||
return preview.href;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [defaultAgent?.pubkey, messages]);
|
||||
|
||||
// Solo chats (you + one agent) read as a plain stream: agent rows drop
|
||||
// their avatar and name. Identities come back as soon as another agent or
|
||||
// person participates, so multi-party chats stay attributable.
|
||||
@@ -462,151 +486,164 @@ export function ChatDetail({
|
||||
transparentChrome
|
||||
/>
|
||||
|
||||
<MessageScrollerProvider
|
||||
autoScroll
|
||||
defaultScrollPosition="end"
|
||||
key={chat.id}
|
||||
scrollEdgeThreshold={48}
|
||||
>
|
||||
<MessageScroller className="bg-background" topFade>
|
||||
<MessageScrollerViewport aria-label="Chat messages">
|
||||
<MessageScrollerContent
|
||||
className={cn(CHAT_CONVERSATION_CLASS, "py-6")}
|
||||
>
|
||||
{isLoadingMessages ? (
|
||||
<MessageScrollerItem messageId="chat:loading">
|
||||
<div className="flex items-center gap-2 px-5 py-1 text-sm text-muted-foreground">
|
||||
<Spinner className="h-4 w-4" />
|
||||
Loading messages
|
||||
</div>
|
||||
</MessageScrollerItem>
|
||||
) : visibleMessages.length === 0 && !hasTranscriptActivity ? (
|
||||
<MessageScrollerItem
|
||||
className="flex flex-1 items-center justify-center"
|
||||
messageId="chat:empty"
|
||||
<div className="flex min-h-0 min-w-0 flex-1">
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<MessageScrollerProvider
|
||||
autoScroll
|
||||
defaultScrollPosition="end"
|
||||
key={chat.id}
|
||||
scrollEdgeThreshold={48}
|
||||
>
|
||||
<MessageScroller className="bg-background" topFade>
|
||||
<MessageScrollerViewport aria-label="Chat messages">
|
||||
<MessageScrollerContent
|
||||
className={cn(CHAT_CONVERSATION_CLASS, "py-6")}
|
||||
>
|
||||
<div className="px-8 py-12 text-center">
|
||||
<MessageCircle className="mx-auto h-5 w-5 text-muted-foreground" />
|
||||
<p className="mt-3 text-sm font-medium">No messages yet</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Send a message and Fizz will respond.
|
||||
</p>
|
||||
</div>
|
||||
</MessageScrollerItem>
|
||||
) : (
|
||||
<>
|
||||
{visibleMessages.map((message) => {
|
||||
const activityBlocks =
|
||||
chatActivity.blocksByMessageId.get(message.id) ?? [];
|
||||
const isContextMessage = eventHasTag(
|
||||
message,
|
||||
"chat_context",
|
||||
"source",
|
||||
);
|
||||
const isAgentMessage =
|
||||
defaultAgent?.pubkey != null &&
|
||||
normalizePubkey(message.pubkey) ===
|
||||
normalizePubkey(defaultAgent.pubkey);
|
||||
const isOwnMessage =
|
||||
identityPubkey != null &&
|
||||
normalizePubkey(message.pubkey) ===
|
||||
normalizePubkey(identityPubkey);
|
||||
|
||||
return (
|
||||
<React.Fragment key={message.localKey ?? message.id}>
|
||||
<MessageScrollerItem
|
||||
className={entranceClassForCreatedAt(
|
||||
message.created_at,
|
||||
)}
|
||||
messageId={message.id}
|
||||
>
|
||||
{isContextMessage ? (
|
||||
<ChatContextRow event={message} />
|
||||
) : (
|
||||
<ChatMessageRow
|
||||
event={message}
|
||||
isAgent={isAgentMessage}
|
||||
isOwn={isOwnMessage}
|
||||
profiles={profiles}
|
||||
showAgentIdentity={showAgentIdentity}
|
||||
/>
|
||||
)}
|
||||
</MessageScrollerItem>
|
||||
{activityBlocks.length > 0 ? (
|
||||
<MessageScrollerItem
|
||||
messageId={`chat:activity:${message.id}`}
|
||||
>
|
||||
<ChatActivityTranscript
|
||||
agent={defaultAgent}
|
||||
blocks={activityBlocks}
|
||||
identityPubkey={identityPubkey}
|
||||
activeTurnIds={activeTurnIds}
|
||||
showAgentIdentity={showAgentIdentity}
|
||||
profiles={profiles}
|
||||
/>
|
||||
</MessageScrollerItem>
|
||||
) : null}
|
||||
{shouldShowAgentActivationCard &&
|
||||
latestVisibleMessage?.id === message.id ? (
|
||||
<MessageScrollerItem
|
||||
messageId={`chat:activate-agent:${message.id}`}
|
||||
>
|
||||
<AgentActivationCard
|
||||
agentName={defaultAgent?.name ?? "Fizz"}
|
||||
isActivating={isActivatingAgent}
|
||||
onActivate={onActivateAgent}
|
||||
/>
|
||||
</MessageScrollerItem>
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{chatActivity.unplacedBlocks.length > 0 ? (
|
||||
<MessageScrollerItem messageId="chat:activity:unplaced">
|
||||
<ChatActivityTranscript
|
||||
agent={defaultAgent}
|
||||
blocks={chatActivity.unplacedBlocks}
|
||||
identityPubkey={identityPubkey}
|
||||
activeTurnIds={activeTurnIds}
|
||||
profiles={profiles}
|
||||
showAgentIdentity={showAgentIdentity}
|
||||
/>
|
||||
{isLoadingMessages ? (
|
||||
<MessageScrollerItem messageId="chat:loading">
|
||||
<div className="flex items-center gap-2 px-5 py-1 text-sm text-muted-foreground">
|
||||
<Spinner className="h-4 w-4" />
|
||||
Loading messages
|
||||
</div>
|
||||
</MessageScrollerItem>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</MessageScrollerContent>
|
||||
</MessageScrollerViewport>
|
||||
<MessageScrollerButton />
|
||||
<ChatScrollAnchor forceSignature={forceScrollSignature} />
|
||||
</MessageScroller>
|
||||
</MessageScrollerProvider>
|
||||
) : visibleMessages.length === 0 && !hasTranscriptActivity ? (
|
||||
<MessageScrollerItem
|
||||
className="flex flex-1 items-center justify-center"
|
||||
messageId="chat:empty"
|
||||
>
|
||||
<div className="px-8 py-12 text-center">
|
||||
<MessageCircle className="mx-auto h-5 w-5 text-muted-foreground" />
|
||||
<p className="mt-3 text-sm font-medium">
|
||||
No messages yet
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Send a message and Fizz will respond.
|
||||
</p>
|
||||
</div>
|
||||
</MessageScrollerItem>
|
||||
) : (
|
||||
<>
|
||||
{visibleMessages.map((message) => {
|
||||
const activityBlocks =
|
||||
chatActivity.blocksByMessageId.get(message.id) ?? [];
|
||||
const isContextMessage = eventHasTag(
|
||||
message,
|
||||
"chat_context",
|
||||
"source",
|
||||
);
|
||||
const isAgentMessage =
|
||||
defaultAgent?.pubkey != null &&
|
||||
normalizePubkey(message.pubkey) ===
|
||||
normalizePubkey(defaultAgent.pubkey);
|
||||
const isOwnMessage =
|
||||
identityPubkey != null &&
|
||||
normalizePubkey(message.pubkey) ===
|
||||
normalizePubkey(identityPubkey);
|
||||
|
||||
<div className="shrink-0 bg-background">
|
||||
<MessageComposer
|
||||
autoInviteNonMemberMentions
|
||||
channelId={chat.id}
|
||||
channelName={chat.name}
|
||||
channelType="chat"
|
||||
containerClassName={cn(CHAT_CONVERSATION_CLASS, "pb-3")}
|
||||
disabled={isSending}
|
||||
draftKey={`chat:${chat.id}`}
|
||||
isSending={isSending}
|
||||
onSend={onSend}
|
||||
placeholder="Message Fizz..."
|
||||
profiles={profiles}
|
||||
toolbarControls={{ emoji: false, formatting: false, spoiler: false }}
|
||||
toolbarExtraActions={
|
||||
<ProjectPicker
|
||||
isNoProjectSelected={!selectedProject && metadata !== null}
|
||||
onCreateProject={onProjectCreated}
|
||||
onSelectProject={handleSelectProject}
|
||||
projects={projects}
|
||||
selectedProject={selectedProject}
|
||||
templates={templates}
|
||||
return (
|
||||
<React.Fragment key={message.localKey ?? message.id}>
|
||||
<MessageScrollerItem
|
||||
className={entranceClassForCreatedAt(
|
||||
message.created_at,
|
||||
)}
|
||||
messageId={message.id}
|
||||
>
|
||||
{isContextMessage ? (
|
||||
<ChatContextRow event={message} />
|
||||
) : (
|
||||
<ChatMessageRow
|
||||
event={message}
|
||||
isAgent={isAgentMessage}
|
||||
isOwn={isOwnMessage}
|
||||
profiles={profiles}
|
||||
showAgentIdentity={showAgentIdentity}
|
||||
/>
|
||||
)}
|
||||
</MessageScrollerItem>
|
||||
{activityBlocks.length > 0 ? (
|
||||
<MessageScrollerItem
|
||||
messageId={`chat:activity:${message.id}`}
|
||||
>
|
||||
<ChatActivityTranscript
|
||||
agent={defaultAgent}
|
||||
blocks={activityBlocks}
|
||||
identityPubkey={identityPubkey}
|
||||
activeTurnIds={activeTurnIds}
|
||||
showAgentIdentity={showAgentIdentity}
|
||||
profiles={profiles}
|
||||
/>
|
||||
</MessageScrollerItem>
|
||||
) : null}
|
||||
{shouldShowAgentActivationCard &&
|
||||
latestVisibleMessage?.id === message.id ? (
|
||||
<MessageScrollerItem
|
||||
messageId={`chat:activate-agent:${message.id}`}
|
||||
>
|
||||
<AgentActivationCard
|
||||
agentName={defaultAgent?.name ?? "Fizz"}
|
||||
isActivating={isActivatingAgent}
|
||||
onActivate={onActivateAgent}
|
||||
/>
|
||||
</MessageScrollerItem>
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{chatActivity.unplacedBlocks.length > 0 ? (
|
||||
<MessageScrollerItem messageId="chat:activity:unplaced">
|
||||
<ChatActivityTranscript
|
||||
agent={defaultAgent}
|
||||
blocks={chatActivity.unplacedBlocks}
|
||||
identityPubkey={identityPubkey}
|
||||
activeTurnIds={activeTurnIds}
|
||||
profiles={profiles}
|
||||
showAgentIdentity={showAgentIdentity}
|
||||
/>
|
||||
</MessageScrollerItem>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</MessageScrollerContent>
|
||||
</MessageScrollerViewport>
|
||||
<MessageScrollerButton />
|
||||
<ChatScrollAnchor forceSignature={forceScrollSignature} />
|
||||
</MessageScroller>
|
||||
</MessageScrollerProvider>
|
||||
|
||||
<div className="shrink-0 bg-background">
|
||||
<MessageComposer
|
||||
autoInviteNonMemberMentions
|
||||
channelId={chat.id}
|
||||
channelName={chat.name}
|
||||
channelType="chat"
|
||||
containerClassName={cn(CHAT_CONVERSATION_CLASS, "pb-3")}
|
||||
disabled={isSending}
|
||||
draftKey={`chat:${chat.id}`}
|
||||
isSending={isSending}
|
||||
onSend={onSend}
|
||||
placeholder="Message Fizz..."
|
||||
profiles={profiles}
|
||||
toolbarControls={{
|
||||
emoji: false,
|
||||
formatting: false,
|
||||
spoiler: false,
|
||||
}}
|
||||
toolbarExtraActions={
|
||||
<ProjectPicker
|
||||
isNoProjectSelected={!selectedProject && metadata !== null}
|
||||
onCreateProject={onProjectCreated}
|
||||
onSelectProject={handleSelectProject}
|
||||
projects={projects}
|
||||
selectedProject={selectedProject}
|
||||
templates={templates}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{agentPullRequestHref ? (
|
||||
<ChatWorkPanel prHref={agentPullRequestHref} />
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { GitBranch } from "lucide-react";
|
||||
|
||||
import {
|
||||
parseGithubPullRequestRef,
|
||||
useGithubPullRequestQuery,
|
||||
} from "@/shared/lib/githubPullRequest";
|
||||
import { parseSupportedLinkPreview } from "@/shared/lib/linkPreview";
|
||||
import { AgentPullRequestCard } from "@/shared/ui/link-preview-attachment";
|
||||
|
||||
/**
|
||||
* Right-hand work module for a chat whose agent produced a pull request:
|
||||
* the PR's source branch and the live PR card (status, diff stats, link).
|
||||
* The conversation column and composer shrink to make room.
|
||||
*/
|
||||
export function ChatWorkPanel({ prHref }: { prHref: string }) {
|
||||
const preview = parseSupportedLinkPreview(prHref);
|
||||
const ref = parseGithubPullRequestRef(prHref);
|
||||
const query = useGithubPullRequestQuery(ref);
|
||||
const branch = query.data?.headRef?.trim();
|
||||
|
||||
if (!preview) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="flex w-80 shrink-0 flex-col gap-3 overflow-y-auto border-l border-border/40 px-4 py-4"
|
||||
data-testid="chat-work-panel"
|
||||
>
|
||||
<div className="text-2xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Work
|
||||
</div>
|
||||
{branch ? (
|
||||
<div className="flex items-center gap-1.5 rounded-lg border border-border/60 bg-muted/20 px-3 py-2 text-xs">
|
||||
<GitBranch className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 truncate font-mono">{branch}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<AgentPullRequestCard preview={preview} />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -225,6 +225,27 @@ export function useAnchoredScroll({
|
||||
container.scrollTo({ top: container.scrollHeight, behavior });
|
||||
setIsAtBottom(true);
|
||||
setNewMessageCount(0);
|
||||
|
||||
// The jump animates toward a scrollHeight snapshot, but virtualized
|
||||
// rows re-measure mid-flight (estimate → real height), moving the
|
||||
// floor. Re-assert the bottom until it stabilizes; re-issuing a
|
||||
// smooth scrollTo re-targets the ongoing glide rather than snapping.
|
||||
let attempts = 0;
|
||||
const reassertBottom = () => {
|
||||
if (anchorRef.current.kind !== "at-bottom") return;
|
||||
const element = scrollContainerRef.current;
|
||||
if (!element) return;
|
||||
const gap =
|
||||
element.scrollHeight - element.scrollTop - element.clientHeight;
|
||||
if (gap > 1) {
|
||||
element.scrollTo({ top: element.scrollHeight, behavior });
|
||||
}
|
||||
attempts += 1;
|
||||
if (attempts < 10 && (gap > 1 || attempts < 3)) {
|
||||
window.setTimeout(reassertBottom, 120);
|
||||
}
|
||||
};
|
||||
window.setTimeout(reassertBottom, 120);
|
||||
},
|
||||
[scrollContainerRef],
|
||||
);
|
||||
|
||||
@@ -2,22 +2,49 @@
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
pointerWithin,
|
||||
useDndContext,
|
||||
useDraggable,
|
||||
useDroppable,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core";
|
||||
import type {
|
||||
CollisionDetection,
|
||||
DragEndEvent,
|
||||
DragStartEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Hash } from "lucide-react";
|
||||
|
||||
// pointerWithin is precise for mouse drags but returns nothing for the
|
||||
// keyboard sensor's synthesized coordinates — fall back to closestCenter so
|
||||
// keyboard reordering can land on a droppable.
|
||||
const sidebarCollisionDetection: CollisionDetection = (args) => {
|
||||
const pointerCollisions = pointerWithin(args);
|
||||
if (pointerCollisions.length > 0) {
|
||||
return pointerCollisions;
|
||||
}
|
||||
// Keyboard path: only sortable siblings are valid targets — the
|
||||
// channel-assignment drop zones overlap the section rows and would
|
||||
// otherwise swallow every keyboard collision.
|
||||
return closestCenter({
|
||||
...args,
|
||||
droppableContainers: args.droppableContainers.filter(
|
||||
(container) => container.data.current?.type !== "section-drop",
|
||||
),
|
||||
});
|
||||
};
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
@@ -61,8 +88,15 @@ export function DroppableSectionBody({
|
||||
className?: string;
|
||||
}) {
|
||||
const droppableId = `section-drop:${sectionId}`;
|
||||
// Sections can only be reordered, never dropped into another section —
|
||||
// disable the channel-assignment zone while a section drag is active so
|
||||
// it never competes for collisions (pointer or keyboard).
|
||||
const { active } = useDndContext();
|
||||
const activeType = (active?.data.current as { type?: string } | undefined)
|
||||
?.type;
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: droppableId,
|
||||
disabled: activeType === "section",
|
||||
data: { type: "section-drop", sectionId } satisfies DndSectionDropData,
|
||||
});
|
||||
|
||||
@@ -187,6 +221,12 @@ export function SidebarDndContext({
|
||||
React.useState<SidebarDragItem | null>(null);
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||
// Keyboard reordering (focus a row, Space to lift, arrows to move,
|
||||
// Space to drop) — also immune to pointer auto-scroll, so tests can
|
||||
// reorder deterministically.
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
const handleDragStart = React.useCallback(
|
||||
@@ -244,7 +284,7 @@ export function SidebarDndContext({
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
collisionDetection={pointerWithin}
|
||||
collisionDetection={sidebarCollisionDetection}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragStart={handleDragStart}
|
||||
sensors={sensors}
|
||||
|
||||
@@ -11,6 +11,8 @@ export type GithubPullRequestInfo = {
|
||||
additions: number;
|
||||
deletions: number;
|
||||
changedFiles: number;
|
||||
/** Source branch of the PR (`head.ref`). */
|
||||
headRef: string;
|
||||
};
|
||||
|
||||
export type GithubPullRequestRef = {
|
||||
|
||||
@@ -8899,6 +8899,7 @@ export function maybeInstallE2eTauriMocks() {
|
||||
additions: 1248,
|
||||
deletions: 96,
|
||||
changedFiles: 24,
|
||||
headRef: "kennylopez-chatmode",
|
||||
number: prPayload.number ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -142,9 +142,14 @@ test("first message in a new chat is sent and rendered", async ({ page }) => {
|
||||
.toBeGreaterThanOrEqual(-1);
|
||||
|
||||
// Agent-authored PR links render the prominent agent-work card variant
|
||||
// (banner layout with status pill), not the compact link chip.
|
||||
// twice: inline in the message and in the top-right work panel (which
|
||||
// also shows the PR's source branch).
|
||||
await expect(
|
||||
page.locator("[data-link-preview='github-pull-request-agent']"),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
).toHaveCount(2, { timeout: 10_000 });
|
||||
await expect(page.getByTestId("chat-work-panel")).toBeVisible();
|
||||
await expect(page.getByTestId("chat-work-panel")).toContainText(
|
||||
"kennylopez-chatmode",
|
||||
);
|
||||
await page.screenshot({ path: "test-results/agent-pr-card.png" });
|
||||
});
|
||||
|
||||
@@ -137,6 +137,10 @@ test.describe("list virtualization", () => {
|
||||
test("06 — custom-section dnd reorder commits under content-visibility", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Tall enough that the sidebar does not scroll: dnd-kit auto-scroll
|
||||
// during the pointer drag would otherwise move the sections away from
|
||||
// their pre-measured drop coordinates and the drop would miss.
|
||||
await page.setViewportSize({ width: 1280, height: 1100 });
|
||||
await seedChannelSections(page);
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
@@ -165,8 +169,8 @@ test.describe("list virtualization", () => {
|
||||
);
|
||||
expect(await sectionOrder()).toEqual(["Priority", "Archive"]);
|
||||
|
||||
// Drag "Priority" past "Archive" — onDragEnd commits arrayMove and persists
|
||||
// the new order. The drop must land for the order to flip.
|
||||
// Drag "Priority" past "Archive" — onDragEnd commits arrayMove and
|
||||
// persists the new order. The drop must land for the order to flip.
|
||||
await dragOver(page, topHeader, bottomHeader);
|
||||
|
||||
// The drop landed: order flipped. A no-op drag would leave it unchanged.
|
||||
|
||||
Reference in New Issue
Block a user