feat(site/src/pages/AgentsPage/components/ChatsSidebar): move PR icon to diff line, center unread dot, persist kebab (#28326)

Three chat-sidebar row updates cherry-picked from #28317 (that PR stays
open as the full riff), plus one removal:

- **PR icon moves to the diff line.** The linked pull request icon (open
/ draft / merged / closed) no longer replaces the status icon when the
chat is idle. It renders on the second line next to the `+additions
-deletions` stats, in both the sidebar tree and the chat search results,
regardless of chat status.
- **Unread dot alignment.** The unread dot is centered in a box matching
the kebab's width, so it sits on the same axis as the actions trigger
instead of shifting when the hover swap happens.
- **Persistent kebab on the selected chat.** The row actions trigger
stays visible on the active chat (no hover required, reachable on touch
devices); the timestamp is hidden there. Other rows keep the hover swap.
- **Subagent count badge removed.** The `N` + bot icon added to the
metadata line in #28234 no longer renders, making room for the PR icon
and diff stats. The "Show subagents" menu toggle and expand behavior are
unchanged.

Status and PR icons carry `role="img"` with accessible labels ("Idle",
"Pull request merged", ...).

<details>
<summary>Implementation notes</summary>

- `statusConfig.ts`: `getChatDisplayConfig(chat)` now returns the status
icon plus a separate `prIcon` config (icon, className, accessible label)
instead of letting the PR icon take precedence over the status. Labels
added for all status and PR states.
- `ChatTreeNode.tsx` / `ChatSearchResults.tsx`: render `prIcon` on the
second line before the line stats.
- Kebab persistence reuses the existing `isActiveChat` value:
`opacity-100` on the trigger, `hidden` on the timestamp for the active
row.
- `ChatTreeNode.tsx`: the on-row subagent count badge (`childIDs.length`
+ `BotIcon`) is removed; the `SubagentsMenuToggle` story only asserts
the menu toggle, so it still passes.
- Not included from #28317: the subagent status icon variants
(`CheckCheckIcon` / `SubagentsLoaderIcon`, the `hasSubagents` param, and
the `WithSubagentStatusIcons` story).

Validation:

- `pnpm run lint` (biome, tsc, knip, circular deps, compiler): pass
- Story tests: 96 passed (4 files) including the updated
`WithPRStateIcons` label assertions and the new
`ActiveChatKebabPersistent` story
- Unit suite for the sidebar area: 65 passed
- `make pre-commit` (via git hook): pass

</details>

_Generated by Coder Agents on behalf of @tracyjohnsonux._
This commit is contained in:
TJ
2026-08-19 21:02:11 +00:00
committed by GitHub
parent 7ba2e6e22a
commit 5be2fa7974
4 changed files with 131 additions and 35 deletions
@@ -1860,6 +1860,53 @@ export const WithPRStateIcons: Story = {
routing: agentsRouting,
}),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await waitFor(() => {
expect(canvas.getByLabelText("Pull request open")).toBeInTheDocument();
expect(canvas.getByLabelText("Draft pull request")).toBeInTheDocument();
expect(canvas.getByLabelText("Pull request merged")).toBeInTheDocument();
expect(canvas.getByLabelText("Pull request closed")).toBeInTheDocument();
});
},
};
export const ActiveChatKebabPersistent: Story = {
args: {
chats: [
buildChat({
id: "active-chat",
title: "Active chat",
updated_at: recentTimestamp,
}),
buildChat({
id: "other-chat",
title: "Other chat",
updated_at: recentTimestamp,
}),
],
},
parameters: {
reactRouter: reactRouterParameters({
location: {
path: "/agents/active-chat",
pathParams: { agentId: "active-chat" },
},
routing: agentsRouting,
}),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const activeTrigger = await canvas.findByLabelText(
"Open actions for Active chat",
);
// The active chat keeps its actions trigger visible without hover.
await waitFor(() => {
expect(window.getComputedStyle(activeTrigger).opacity).toBe("1");
});
const otherTrigger = canvas.getByLabelText("Open actions for Other chat");
expect(window.getComputedStyle(otherTrigger).opacity).toBe("0");
},
};
export const WithUnreadChats: Story = {
@@ -255,8 +255,11 @@ const ChatSearchResultRow: FC<ChatSearchResultRowProps> = ({
const {
icon: StatusIcon,
className: statusClassName,
label: statusLabel,
prIcon,
diffStatus,
} = getChatDisplayConfig(chat);
const PRIcon = prIcon?.icon;
const additions = diffStatus?.additions ?? 0;
const deletions = diffStatus?.deletions ?? 0;
const changedFiles = diffStatus?.changed_files ?? 0;
@@ -285,12 +288,23 @@ const ChatSearchResultRow: FC<ChatSearchResultRowProps> = ({
isSelected && "bg-surface-tertiary/40 text-content-primary",
)}
>
<StatusIcon className={cn("mt-1 size-3.5 shrink-0", statusClassName)} />
<StatusIcon
role="img"
aria-label={statusLabel}
className={cn("mt-1 size-3.5 shrink-0", statusClassName)}
/>
<div className="min-w-0">
<div className="truncate text-sm text-content-primary">
{chat.title}
</div>
<div className="flex min-w-0 items-center gap-1.5 text-xs">
{PRIcon && prIcon && (
<PRIcon
role="img"
aria-label={prIcon.label}
className={cn("size-3.5 shrink-0", prIcon.className)}
/>
)}
{hasLineStats && (
<span className="inline-flex shrink-0 items-center gap-0.5 tabular-nums">
<span className="text-git-added-bright">+{additions}</span>
@@ -1,5 +1,4 @@
import {
BotIcon,
ChevronDownIcon,
ChevronRightIcon,
EllipsisVerticalIcon,
@@ -127,8 +126,11 @@ export const ChatTreeNode: FC<ChatTreeNodeProps> = ({ chat, isChildNode }) => {
const {
icon: StatusIcon,
className: statusClassName,
label: statusLabel,
prIcon,
diffStatus,
} = getChatDisplayConfig(chat);
const PRIcon = prIcon?.icon;
const hasLinkedDiffStatus = Boolean(diffStatus?.url);
const changedFiles = diffStatus?.changed_files ?? 0;
const additions = diffStatus?.additions ?? 0;
@@ -199,6 +201,8 @@ export const ChatTreeNode: FC<ChatTreeNodeProps> = ({ chat, isChildNode }) => {
? `agents-tree-executing-${chat.id}`
: undefined
}
role="img"
aria-label={statusLabel}
className={cn("size-3.5 shrink-0", statusClassName)}
/>
</div>
@@ -242,16 +246,12 @@ export const ChatTreeNode: FC<ChatTreeNodeProps> = ({ chat, isChildNode }) => {
)}
</div>
<div className="flex min-w-0 items-center gap-1.5">
{hasChildren && (
<span
className="inline-flex shrink-0 items-center gap-0.5 text-[13px] leading-4 tabular-nums text-content-secondary"
title={`${childIDs.length} ${
childIDs.length === 1 ? "subagent" : "subagents"
}`}
>
{childIDs.length}
<BotIcon className="size-3.5" aria-hidden="true" />
</span>
{PRIcon && prIcon && (
<PRIcon
role="img"
aria-label={prIcon.label}
className={cn("size-3.5 shrink-0", prIcon.className)}
/>
)}
{hasLinkedDiffStatus && hasLineStats && (
<span
@@ -297,14 +297,17 @@ export const ChatTreeNode: FC<ChatTreeNodeProps> = ({ chat, isChildNode }) => {
// keep the timestamp visible.
hasMenuActions &&
"[@media(hover:hover)]:group-hover:hidden group-has-[[data-state=open]]:hidden",
hasMenuActions && isActiveChat && "hidden",
)}
>
{chat.has_unread && !isActiveChat ? (
<span
className="size-2 shrink-0 rounded-full bg-content-link pr-1"
data-testid={`unread-indicator-${chat.id}`}
aria-hidden="true"
/>
<span className="flex w-3.5 shrink-0 justify-center">
<span
className="size-2 rounded-full bg-content-link"
data-testid={`unread-indicator-${chat.id}`}
aria-hidden="true"
/>
</span>
) : (
<>
{/* Pin the ignored mask width so Pixel does not diff bounding rect changes. */}
@@ -331,7 +334,10 @@ export const ChatTreeNode: FC<ChatTreeNodeProps> = ({ chat, isChildNode }) => {
<Button
size="icon"
variant="subtle"
className="absolute inset-0 flex h-6 w-7 min-w-0 justify-end rounded-none px-0 opacity-0 text-content-secondary hover:text-content-primary [@media(hover:hover)]:group-hover:opacity-100 data-[state=open]:opacity-100"
className={cn(
"absolute inset-0 flex h-6 w-7 min-w-0 justify-end rounded-none px-0 opacity-0 text-content-secondary hover:text-content-primary [@media(hover:hover)]:group-hover:opacity-100 data-[state=open]:opacity-100",
isActiveChat && "opacity-100",
)}
aria-label={`Open actions for ${chat.title}`}
>
<EllipsisVerticalIcon className="size-3.5" />
@@ -14,15 +14,36 @@ import type { Chat, ChatDiffStatus, ChatStatus } from "#/api/typesGenerated";
type ChatIconConfig = {
icon: LucideIcon;
className: string;
label: string;
};
const statusConfig = {
waiting: { icon: CheckIcon, className: "text-content-secondary" },
running: { icon: Loader2Icon, className: "text-content-link animate-spin" },
interrupting: { icon: PauseIcon, className: "text-content-warning" },
requires_action: { icon: PauseIcon, className: "text-content-warning" },
error: { icon: AlertTriangleIcon, className: "text-content-destructive" },
} as const;
waiting: {
icon: CheckIcon,
className: "text-content-secondary",
label: "Idle",
},
running: {
icon: Loader2Icon,
className: "text-content-link animate-spin",
label: "Working",
},
interrupting: {
icon: PauseIcon,
className: "text-content-warning",
label: "Interrupting",
},
requires_action: {
icon: PauseIcon,
className: "text-content-warning",
label: "Requires action",
},
error: {
icon: AlertTriangleIcon,
className: "text-content-destructive",
label: "Error",
},
} as const satisfies Record<ChatStatus, ChatIconConfig>;
const getStatusConfig = (status: ChatStatus): ChatIconConfig => {
return statusConfig[status] ?? statusConfig.waiting;
@@ -36,21 +57,31 @@ const getPRIconConfig = (
return undefined;
}
if (state === "merged") {
return { icon: GitMergeIcon, className: "text-git-merged-bright" };
return {
icon: GitMergeIcon,
className: "text-git-merged-bright",
label: "Pull request merged",
};
}
if (state === "closed") {
return {
icon: GitPullRequestClosedIcon,
className: "text-git-deleted-bright",
label: "Pull request closed",
};
}
if (diffStatus?.pull_request_draft) {
return {
icon: GitPullRequestDraftIcon,
className: "text-content-secondary",
label: "Draft pull request",
};
}
return { icon: GitPullRequestArrowIcon, className: "text-git-added-bright" };
return {
icon: GitPullRequestArrowIcon,
className: "text-git-added-bright",
label: "Pull request open",
};
};
const getChatDiffStatus = (chat: Chat): ChatDiffStatus | undefined => {
@@ -58,28 +89,26 @@ const getChatDiffStatus = (chat: Chat): ChatDiffStatus | undefined => {
};
/**
* Returns the icon and styling that represents a chat's current state.
* Returns the icons and styling that represent a chat's current state.
*
* Combines `getStatusConfig` and `getPRIconConfig`: when the chat is in the
* settled `waiting` state and has a linked PR, the PR icon takes precedence
* so list rows surface the merge / closed / draft state instead of the
* generic status icon.
* The status icon always reflects the chat status.
*/
export const getChatDisplayConfig = (
chat: Chat,
): {
icon: LucideIcon;
className: string;
label: string;
prIcon: ChatIconConfig | undefined;
diffStatus: ChatDiffStatus | undefined;
} => {
const diffStatus = getChatDiffStatus(chat);
const baseConfig = getStatusConfig(chat.status);
const prConfig =
chat.status === "waiting" ? getPRIconConfig(diffStatus) : undefined;
const config = prConfig ?? baseConfig;
const config = getStatusConfig(chat.status);
return {
icon: config.icon,
className: config.className,
label: config.label,
prIcon: getPRIconConfig(diffStatus),
diffStatus,
};
};