mirror of
https://github.com/coder/coder.git
synced 2026-10-31 02:43:21 +08:00
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:
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user