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:
klopez4212
2026-07-07 07:46:13 +01:00
co-authored by Claude Fable 5
parent ecaa8573d5
commit f19c35e025
12 changed files with 312 additions and 158 deletions
@@ -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 ? (
+178 -141
View File
@@ -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],
);
+42 -2
View File
@@ -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 = {
+1
View File
@@ -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" });
});
+6 -2
View File
@@ -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.