diff --git a/desktop/src-tauri/src/commands/link_preview.rs b/desktop/src-tauri/src/commands/link_preview.rs index 31aece747..086c7016b 100644 --- a/desktop/src-tauri/src/commands/link_preview.rs +++ b/desktop/src-tauri/src/commands/link_preview.rs @@ -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(), })) } diff --git a/desktop/src/features/chats/ui/ChatActivityMarkerRow.tsx b/desktop/src/features/chats/ui/ChatActivityMarkerRow.tsx index 505eccb1d..6989112ed 100644 --- a/desktop/src/features/chats/ui/ChatActivityMarkerRow.tsx +++ b/desktop/src/features/chats/ui/ChatActivityMarkerRow.tsx @@ -36,7 +36,7 @@ export function ActivityMarkerRow({ className={cn("py-1.5", entrance && "buzz-message-entrance")} side="left" > - + {details ? (
diff --git a/desktop/src/features/chats/ui/ChatActivityTranscript.tsx b/desktop/src/features/chats/ui/ChatActivityTranscript.tsx index 21f9fc8cb..b0d42d130 100644 --- a/desktop/src/features/chats/ui/ChatActivityTranscript.tsx +++ b/desktop/src/features/chats/ui/ChatActivityTranscript.tsx @@ -480,11 +480,9 @@ function ChatTranscriptMessageRow({ ) : null} - {!hideIdentity ? ( - - - {isUser ? "You" : label} - + {!hideIdentity && !isUser ? ( + + {label} ) : null} {isUser ? ( diff --git a/desktop/src/features/chats/ui/ChatConversationRows.tsx b/desktop/src/features/chats/ui/ChatConversationRows.tsx index c44a70e56..9d291597c 100644 --- a/desktop/src/features/chats/ui/ChatConversationRows.tsx +++ b/desktop/src/features/chats/ui/ChatConversationRows.tsx @@ -73,11 +73,12 @@ export function ChatMessageRow({ - {!hideIdentity ? ( - - - {isOwn ? "You" : displayName} - + {/* 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 ? ( + + {displayName} ) : null} {isAgent ? ( diff --git a/desktop/src/features/chats/ui/ChatDetail.tsx b/desktop/src/features/chats/ui/ChatDetail.tsx index 778e27b68..0664b0b7c 100644 --- a/desktop/src/features/chats/ui/ChatDetail.tsx +++ b/desktop/src/features/chats/ui/ChatDetail.tsx @@ -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 /> - - - - - {isLoadingMessages ? ( - -
- - Loading messages -
-
- ) : visibleMessages.length === 0 && !hasTranscriptActivity ? ( - +
+ + + + -
- -

No messages yet

-

- Send a message and Fizz will respond. -

-
- - ) : ( - <> - {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 ( - - - {isContextMessage ? ( - - ) : ( - - )} - - {activityBlocks.length > 0 ? ( - - - - ) : null} - {shouldShowAgentActivationCard && - latestVisibleMessage?.id === message.id ? ( - - - - ) : null} - - ); - })} - {chatActivity.unplacedBlocks.length > 0 ? ( - - + {isLoadingMessages ? ( + +
+ + Loading messages +
- ) : null} - - )} -
-
- - -
-
+ ) : visibleMessages.length === 0 && !hasTranscriptActivity ? ( + +
+ +

+ No messages yet +

+

+ Send a message and Fizz will respond. +

+
+
+ ) : ( + <> + {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); -
- + + {isContextMessage ? ( + + ) : ( + + )} + + {activityBlocks.length > 0 ? ( + + + + ) : null} + {shouldShowAgentActivationCard && + latestVisibleMessage?.id === message.id ? ( + + + + ) : null} + + ); + })} + {chatActivity.unplacedBlocks.length > 0 ? ( + + + + ) : null} + + )} + + + + + + + +
+ + } /> - } - /> +
+
+ {agentPullRequestHref ? ( + + ) : null}
); diff --git a/desktop/src/features/chats/ui/ChatWorkPanel.tsx b/desktop/src/features/chats/ui/ChatWorkPanel.tsx new file mode 100644 index 000000000..bc3337a7f --- /dev/null +++ b/desktop/src/features/chats/ui/ChatWorkPanel.tsx @@ -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 ( + + ); +} diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.ts b/desktop/src/features/messages/ui/useAnchoredScroll.ts index 2c0176df7..9146c1c49 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.ts +++ b/desktop/src/features/messages/ui/useAnchoredScroll.ts @@ -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], ); diff --git a/desktop/src/features/sidebar/ui/SidebarDnd.tsx b/desktop/src/features/sidebar/ui/SidebarDnd.tsx index 3ed92e65d..f927772f7 100644 --- a/desktop/src/features/sidebar/ui/SidebarDnd.tsx +++ b/desktop/src/features/sidebar/ui/SidebarDnd.tsx @@ -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(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 ( { .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" }); }); diff --git a/desktop/tests/e2e/virtualization.spec.ts b/desktop/tests/e2e/virtualization.spec.ts index 1270e51dd..fe3aa3734 100644 --- a/desktop/tests/e2e/virtualization.spec.ts +++ b/desktop/tests/e2e/virtualization.spec.ts @@ -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.