From 0da70895d7f2baafb9c8c49b321dfcb64f5e028f Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 10 Aug 2026 03:21:03 -0400 Subject: [PATCH 01/17] fix: support spaced chat mentions and multi-PR cards --- .../chat/AgentChatComposer.test.tsx | 32 ++++ .../components/chat/AgentChatComposer.tsx | 13 +- .../components/lanes/LanePrBadgePopover.tsx | 95 ++++++------ .../components/lanes/LanePrHoverCard.tsx | 144 ++++++++++++++++++ .../components/terminals/LanePrBadge.test.tsx | 17 ++- .../components/terminals/LanePrBadge.tsx | 126 +++++++-------- .../src/renderer/lib/lanePrBadge.test.ts | 31 +++- apps/desktop/src/renderer/lib/lanePrBadge.ts | 31 ++-- .../src/shared/composerTriggers.test.ts | 13 ++ apps/desktop/src/shared/composerTriggers.ts | 13 +- .../Work/WorkComposerTypedTriggers.swift | 10 +- .../WorkComposerTriggerDetectorTests.swift | 15 +- docs/features/chat/composer-and-ui.md | 8 +- docs/features/lanes/README.md | 5 +- .../features/terminals-and-sessions/README.md | 6 +- .../terminals-and-sessions/ui-surfaces.md | 5 +- 16 files changed, 415 insertions(+), 149 deletions(-) create mode 100644 apps/desktop/src/renderer/components/lanes/LanePrHoverCard.tsx diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index 01652259b..9d9db1766 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -682,6 +682,38 @@ describe("AgentChatComposer", () => { expect(await screen.findByText("App.tsx")).toBeTruthy(); }); + it("keeps spaced chat mentions searchable and displays the chat title in the chip", async () => { + const onSearchMentions = vi.fn().mockResolvedValue([{ + kind: "chat" as const, + id: "chat-1", + title: "a b c", + subtitle: "Primary · codex", + }]); + const props = buildComposerProps({ + turnActive: false, + draft: "", + onSearchMentions, + }); + const view = render(); + const textbox = screen.getByRole("textbox"); + + fireEvent.change(textbox, { + target: { value: "@a b c", selectionStart: 6 }, + }); + view.rerender(); + + await waitFor(() => expect(onSearchMentions).toHaveBeenCalledWith("a b c")); + fireEvent.click(await screen.findByText("a b c")); + + expect(props.onDraftChange).toHaveBeenLastCalledWith("@chat:chat-1 "); + view.rerender(); + + const chip = await screen.findByText("a b c"); + expect(chip.textContent).toBe("a b c"); + expect(chip.closest("[aria-hidden]")).not.toBeNull(); + expect(view.container.querySelector("[aria-hidden]")?.textContent).not.toContain("@chat:chat-1"); + }); + it("uses lane attachment search for at-command suggestions before a session exists", async () => { const onSearchAttachments = vi.fn().mockResolvedValue([{ path: "docs/README.md", type: "file" }]); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index ac1aecf4b..c9bb77b7d 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -1853,6 +1853,12 @@ export function AgentChatComposer({ const richEditorRef = useRef(null); const richSelectionRef = useRef(null); const richInitializedRef = useRef(false); + // Plain textarea chips are painted by an overlay, while the serialized + // draft intentionally stores only the opaque mention pointer. Keep the + // selected row's title separately so the visible chip stays user-facing. + // This is a presentation cache only; send-time parsing still uses the + // canonical @chat: token in `draft`. + const mentionLabelsRef = useRef>(new Map()); const lastSerializedDraftRef = useRef(""); const lastPlainSelectionRef = useRef(null); const fileAddInProgressRef = useRef(false); @@ -2004,12 +2010,16 @@ export function AgentChatComposer({ let pos = 0; plainComposerTokens.forEach((token, index) => { if (token.start > pos) segments.push(draft.slice(pos, token.start)); + const tokenText = draft.slice(token.start, token.end); + const displayText = token.kind === "mention" + ? mentionLabelsRef.current.get(tokenText)?.trim() || tokenText + : tokenText; segments.push( - {draft.slice(token.start, token.end)} + {displayText} , ); pos = token.end; @@ -3886,6 +3896,7 @@ export function AgentChatComposer({ // A mention is a pointer, not an attachment: nothing is resolved or read // now. The token is expanded into an block at send time. const token = formatChatMentionToken(item.mention.kind, item.mention.id); + mentionLabelsRef.current.set(token, item.mention.title); if (useRichComposer) { if (!replaceRichTriggerWith({ chipKind: "mention", diff --git a/apps/desktop/src/renderer/components/lanes/LanePrBadgePopover.tsx b/apps/desktop/src/renderer/components/lanes/LanePrBadgePopover.tsx index fd6095d73..552a4eef8 100644 --- a/apps/desktop/src/renderer/components/lanes/LanePrBadgePopover.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanePrBadgePopover.tsx @@ -15,7 +15,8 @@ import { import { formatPrBadgeLabel } from "../prs/shared/prFormatters"; import { GitHubStackBadge } from "../prs/shared/GitHubStackBadge"; import { NO_CI_REASON } from "../../../shared/prChecksRollup"; -import { lanePrAttention, lanePrAttentionColor, lanePrAttentionRank } from "../../lib/lanePrBadge"; +import { lanePrAggregateAttention, lanePrAttention, lanePrAttentionColor, pickPrimaryPr } from "../../lib/lanePrBadge"; +import { LanePrHoverCard } from "./LanePrHoverCard"; /** Caption beneath the state badge: "PR opened / merged / draft / closed". */ function prStateCaption(state: LaneTabPrTag["state"]): string { @@ -92,24 +93,57 @@ export function LanePrBadgePopover({ onOpenList?: () => void; }) { const allPrs = prs?.length ? prs : legacyPr ? [legacyPr] : []; - const primaryPr = allPrs.length > 1 - ? allPrs.reduce((best, candidate) => ( - lanePrAttentionRank(candidate) > lanePrAttentionRank(best) ? candidate : best - ), allPrs[0]!) - : allPrs[0] ?? null; + const primaryPr = pickPrimaryPr(allPrs) ?? allPrs[0] ?? null; if (!primaryPr) return null; if (allPrs.length > 1) { - const aggregateColor = lanePrAttentionColor(lanePrAttention(primaryPr)); + const aggregateColor = lanePrAttentionColor(lanePrAggregateAttention(allPrs)); const countClass = "rounded-full border border-white/[0.08] bg-white/[0.03] px-1.5 py-px font-mono text-[9px] font-semibold text-muted-fg/65"; const activate = (event: React.SyntheticEvent, candidate = primaryPr) => { event.stopPropagation(); onActivate(event, candidate); }; return ( - event.stopPropagation()} - onMouseDown={(event) => event.stopPropagation()} + +
+ Pull requests ({allPrs.length}) +
+ {allPrs.map((candidate) => ( +
activate(event, candidate)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + activate(event, candidate); + } + }} + title={candidate.title} + > + + + + #{candidate.githubPrNumber} + {candidate.state} + {candidate.laneRole === "previous" ? previous : null} + + {candidate.title || "Untitled pull request"} + + + + + +
+ ))} + + )} >