Skip to content

Commit 3aeeb99

Browse files
authored
🤖 fix: align sidebar parent/sub-agent connectors (#3129)
## Summary Derive the left-sidebar sub-agent connector from shared sidebar geometry so the default parent→child rail lands on both status indicators instead of relying on disconnected offsets. ## Background The connector between a parent agent row and its sub-agent rows had drifted out of alignment because the rail and elbow geometry were encoded with hardcoded offsets that were no longer tied to the actual leading status-slot centers. That made the connector fragile whenever surrounding row layout changed. ## Implementation - added `sidebarItemLayout.ts` to centralize sidebar padding and leading-slot geometry - updated `AgentListItem` to compute the parent rail and child endpoint from that shared layout instead of ad hoc offsets - updated `SubAgentListItem` to draw the elbow from explicit x-coordinates, which makes the default connector alignment correct by construction - reused the shared padding helper in `TaskGroupListItem` so grouped rows stay on the same indentation grid - added focused helper/component/integration tests for the connector geometry ## Validation - `make static-check` - `bun test src/browser/components/sidebarItemLayout.test.ts src/browser/components/AgentListItem/SubAgentListItem.test.tsx src/browser/components/AgentListItem/AgentListItem.test.tsx` - visually verified Storybook stories for `App Sidebar Three Active Sub-Agents` and `BestOfSubagents` ## Risks Low-to-medium risk in left-sidebar row layout and grouped sub-agent rendering. The main mitigation is that the connector now derives from shared layout primitives, and the new tests cover both the geometry helpers and rendered connector positions. --- _Generated with `mux` • Model: `openai:gpt-5.4` • Thinking: `xhigh` • Cost: `$6.76`_ <!-- mux-attribution: model=openai:gpt-5.4 thinking=xhigh costs=6.76 -->
1 parent f1e7c9f commit 3aeeb99

7 files changed

Lines changed: 254 additions & 65 deletions

File tree

src/browser/components/AgentListItem/AgentListItem.test.tsx

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ function renderWorkspaceItem(
161161
metadata?: FrontendWorkspaceMetadata;
162162
isSelected?: boolean;
163163
isArchiving?: boolean;
164+
depth?: number;
164165
rowRenderMeta?: AgentRowRenderMeta;
165166
completedChildrenExpanded?: boolean;
166167
onToggleCompletedChildren?: (workspaceId: string) => void;
@@ -174,6 +175,7 @@ function renderWorkspaceItem(
174175
projectName={metadata.projectName}
175176
isSelected={options.isSelected ?? false}
176177
isArchiving={options.isArchiving}
178+
depth={options.depth ?? options.rowRenderMeta?.depth}
177179
rowRenderMeta={options.rowRenderMeta}
178180
completedChildrenExpanded={options.completedChildrenExpanded}
179181
onToggleCompletedChildren={options.onToggleCompletedChildren}
@@ -242,13 +244,39 @@ describe("AgentListItem", () => {
242244

243245
expect(heartbeatIcon).toBeTruthy();
244246
expect(heartbeatIcon.parentElement?.className).toContain(
245-
"relative z-20 flex h-4 w-4 shrink-0 items-center justify-center self-center"
247+
"relative z-20 flex shrink-0 items-center justify-center self-center"
246248
);
249+
expect(heartbeatIcon.parentElement?.getAttribute("style")).toContain("width: 16px");
250+
expect(heartbeatIcon.parentElement?.getAttribute("style")).toContain("height: 16px");
247251
expect(
248252
rowView.queryByRole("button", { name: `Archive workspace ${TEST_WORKSPACE_TITLE}` })
249253
).toBeNull();
250254
});
251255

256+
test("anchors sub-agent connectors to the parent and child leading status slots", () => {
257+
const { view } = renderWorkspaceItem({
258+
depth: 1,
259+
rowRenderMeta: {
260+
depth: 1,
261+
rowKind: "subagent",
262+
connectorPosition: "single",
263+
connectorStartsAtParent: true,
264+
sharedTrunkActiveThroughRow: false,
265+
sharedTrunkActiveBelowRow: false,
266+
ancestorTrunks: [],
267+
hasHiddenCompletedChildren: false,
268+
visibleCompletedChildrenCount: 0,
269+
},
270+
});
271+
272+
const topSegment = view.getByTestId("subagent-connector-top-segment");
273+
const elbow = view.getByTestId("subagent-connector-elbow");
274+
275+
expect(topSegment.getAttribute("style")).toContain("left: 18px");
276+
expect(elbow.getAttribute("style")).toContain("left: 18px");
277+
expect(elbow.getAttribute("style")).toContain("width: 8px");
278+
});
279+
252280
test("does not render a heartbeat icon fallback when completed children indicator is shown", () => {
253281
mockWorkspaceHeartbeatsEnabled = true;
254282

src/browser/components/AgentListItem/AgentListItem.tsx

Lines changed: 32 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import { useTitleEdit } from "@/browser/contexts/WorkspaceTitleEditContext";
2-
import { stopKeyboardPropagation } from "@/browser/utils/events";
3-
import type { AgentRowRenderMeta } from "@/browser/utils/ui/workspaceFiltering";
4-
import { cn } from "@/common/lib/utils";
5-
import { useRuntimeStatus } from "@/browser/stores/RuntimeStatusStore";
62
import { updatePersistedState } from "@/browser/hooks/usePersistedState";
3+
import { useContextMenuPosition } from "@/browser/hooks/useContextMenuPosition";
4+
import { useExperimentValue } from "@/browser/hooks/useExperiments";
5+
import { useWorkspaceFallbackModel } from "@/browser/hooks/useWorkspaceFallbackModel";
76
import { useWorkspaceUnread } from "@/browser/hooks/useWorkspaceUnread";
7+
import { useRuntimeStatus } from "@/browser/stores/RuntimeStatusStore";
88
import { useWorkspaceSidebarState } from "@/browser/stores/WorkspaceStore";
9-
import { useWorkspaceFallbackModel } from "@/browser/hooks/useWorkspaceFallbackModel";
10-
import { useExperimentValue } from "@/browser/hooks/useExperiments";
9+
import { stopKeyboardPropagation } from "@/browser/utils/events";
10+
import type { AgentRowRenderMeta } from "@/browser/utils/ui/workspaceFiltering";
11+
import { cn } from "@/common/lib/utils";
1112
import {
1213
TASK_GROUP_KIND,
1314
getTaskGroupKindFromMetadata,
@@ -25,8 +26,15 @@ import { SubAgentListItem } from "./SubAgentListItem";
2526

2627
import { Tooltip, TooltipTrigger, TooltipContent } from "../Tooltip/Tooltip";
2728
import { Popover, PopoverContent, PopoverTrigger, PopoverAnchor } from "../Popover/Popover";
28-
import { useContextMenuPosition } from "@/browser/hooks/useContextMenuPosition";
2929
import { PositionedMenu, PositionedMenuItem } from "../PositionedMenu/PositionedMenu";
30+
import {
31+
getAncestorRailX,
32+
getSidebarItemPaddingLeft,
33+
getSubAgentChildStatusCenterX,
34+
getSubAgentParentRailX,
35+
SIDEBAR_LEADING_SLOT_SIZE_PX,
36+
type SubAgentConnectorLayout,
37+
} from "../sidebarItemLayout";
3038
import {
3139
Trash2,
3240
Trash,
@@ -86,7 +94,7 @@ export interface AgentListItemProps extends AgentListItemBaseProps {
8694
variant?: "workspace";
8795
metadata: FrontendWorkspaceMetadata;
8896
projectName: string;
89-
subAgentConnectorLayout?: "default" | "task-group-member";
97+
subAgentConnectorLayout?: SubAgentConnectorLayout;
9098
isArchiving?: boolean;
9199
/** True when deletion is in-flight (optimistic UI while backend removes). */
92100
isRemoving?: boolean;
@@ -125,25 +133,10 @@ const HIDE_INLINE_ACTIONS_ON_MOBILE_TOUCH =
125133
const SHOW_INLINE_ACTIONS_ON_WIDE_TOUCH =
126134
"[@media(min-width:769px)_and_(hover:none)_and_(pointer:coarse)]:opacity-100";
127135

128-
/** Calculate left padding based on nesting depth.
129-
* Base 10px places the status dot center at 18px — aligned with the project
130-
* folder icon center (pl-2 8px + half of h-5 w-5 button 10px = 18px). */
131-
function getItemPaddingLeft(depth?: number): number {
132-
const safeDepth = typeof depth === "number" && Number.isFinite(depth) ? Math.max(0, depth) : 0;
133-
return 10 + Math.min(32, safeDepth) * 8;
134-
}
135-
136-
function getSubAgentConnectorLeft(
137-
indentLeft: number,
138-
layout: "default" | "task-group-member"
139-
): number {
140-
return layout === "task-group-member" ? indentLeft - 2 : indentLeft + 9;
141-
}
142-
143-
function getAncestorTrunkLeft(depth: number, layout: "default" | "task-group-member"): number {
144-
const indentLeft = getItemPaddingLeft(depth);
145-
return layout === "task-group-member" ? indentLeft + 6 : indentLeft + 8;
146-
}
136+
const LEADING_SLOT_CONTAINER_STYLE = {
137+
width: SIDEBAR_LEADING_SLOT_SIZE_PX,
138+
height: SIDEBAR_LEADING_SLOT_SIZE_PX,
139+
} as const;
147140

148141
type VisualState = "active" | "idle" | "seen" | "hidden" | "error" | "question";
149142

@@ -193,7 +186,7 @@ function isStatusDotVisible(state: VisualState, isDraft?: boolean, isSubAgent?:
193186
}
194187

195188
const LEADING_SLOT_CONTAINER_CLASSES =
196-
"relative z-20 flex h-4 w-4 shrink-0 items-center justify-center self-center";
189+
"relative z-20 flex shrink-0 items-center justify-center self-center";
197190

198191
function HeartbeatFallbackIcon() {
199192
return (
@@ -238,6 +231,7 @@ function StatusDot(props: {
238231
// Keep the status dot above sub-agent connector overlays so branch lines do
239232
// not draw across the dot when rows are nested.
240233
className={LEADING_SLOT_CONTAINER_CLASSES}
234+
style={LEADING_SLOT_CONTAINER_STYLE}
241235
>
242236
{dot}
243237
{props.overlay && (
@@ -254,7 +248,7 @@ function QuickArchiveButton(props: {
254248
onArchiveWorkspace: (button: HTMLElement) => void;
255249
}) {
256250
return (
257-
<div className={LEADING_SLOT_CONTAINER_CLASSES}>
251+
<div className={LEADING_SLOT_CONTAINER_CLASSES} style={LEADING_SLOT_CONTAINER_STYLE}>
258252
<Tooltip>
259253
<TooltipTrigger asChild>
260254
<button
@@ -297,7 +291,7 @@ function ActionButtonWrapper(props: { children: React.ReactNode; className?: str
297291

298292
function DraftAgentListItemInner(props: DraftAgentListItemProps) {
299293
const { projectPath, isSelected, depth, sectionId, draft } = props;
300-
const paddingLeft = getItemPaddingLeft(depth);
294+
const paddingLeft = getSidebarItemPaddingLeft(depth);
301295
const hasPromptPreview = draft.promptPreview.length > 0;
302296
const draftBorderStyle: React.CSSProperties = {
303297
backgroundImage: [
@@ -658,7 +652,7 @@ function RegularAgentListItemInner(props: AgentListItemProps) {
658652
? "text-content-tertiary"
659653
: "text-content-primary";
660654

661-
const paddingLeft = getItemPaddingLeft(depth);
655+
const paddingLeft = getSidebarItemPaddingLeft(depth);
662656

663657
// Drag handle for moving workspace between sections
664658
const [{ isDragging }, drag, dragPreview] = useDrag(
@@ -786,7 +780,7 @@ function RegularAgentListItemInner(props: AgentListItemProps) {
786780
data-section-id={sectionId ?? ""}
787781
>
788782
{shouldShowHeartbeatFallback ? (
789-
<div className={LEADING_SLOT_CONTAINER_CLASSES}>
783+
<div className={LEADING_SLOT_CONTAINER_CLASSES} style={LEADING_SLOT_CONTAINER_STYLE}>
790784
<HeartbeatFallbackIcon />
791785
</div>
792786
) : shouldShowQuickArchiveButton ? (
@@ -1117,15 +1111,12 @@ function AgentListItemInner(props: UnifiedAgentListItemProps) {
11171111
// Connector geometry is driven by render metadata so visible siblings keep
11181112
// consistent single/middle/last shapes as parents expand/collapse children.
11191113
const isElbowActive = props.metadata.taskStatus === "running";
1120-
// Task-group members use a slightly different left rail so their connector
1121-
// trunk aligns with the group's leading chevron column.
11221114
const connectorLayout = props.subAgentConnectorLayout ?? "default";
1123-
const connectorLeft = getSubAgentConnectorLeft(
1124-
getItemPaddingLeft(props.depth),
1125-
connectorLayout
1126-
);
1115+
const connectorDepth = props.depth ?? rowMeta.depth;
1116+
const connectorRailX = getSubAgentParentRailX(connectorDepth, connectorLayout);
1117+
const childStatusCenterX = getSubAgentChildStatusCenterX(connectorDepth);
11271118
const ancestorTrunks = rowMeta.ancestorTrunks.map((trunk) => ({
1128-
left: getAncestorTrunkLeft(trunk.depth, connectorLayout),
1119+
left: getAncestorRailX(trunk.depth, connectorLayout),
11291120
active: trunk.active,
11301121
}));
11311122

@@ -1136,7 +1127,8 @@ function AgentListItemInner(props: UnifiedAgentListItemProps) {
11361127
sharedTrunkActiveThroughRow={rowMeta.sharedTrunkActiveThroughRow}
11371128
sharedTrunkActiveBelowRow={rowMeta.sharedTrunkActiveBelowRow}
11381129
ancestorTrunks={ancestorTrunks}
1139-
connectorLeft={connectorLeft}
1130+
connectorRailX={connectorRailX}
1131+
childStatusCenterX={childStatusCenterX}
11401132
isSelected={props.isSelected}
11411133
isElbowActive={isElbowActive}
11421134
>
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import "../../../../tests/ui/dom";
2+
3+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4+
import { cleanup, render } from "@testing-library/react";
5+
import { installDom } from "../../../../tests/ui/dom";
6+
7+
import { SubAgentListItem } from "./SubAgentListItem";
8+
9+
describe("SubAgentListItem", () => {
10+
let cleanupDom: (() => void) | null = null;
11+
12+
beforeEach(() => {
13+
cleanupDom = installDom();
14+
});
15+
16+
afterEach(() => {
17+
cleanup();
18+
cleanupDom?.();
19+
cleanupDom = null;
20+
});
21+
22+
test("draws the elbow from the parent rail into the child status center", () => {
23+
const view = render(
24+
<SubAgentListItem
25+
connectorPosition="single"
26+
connectorStartsAtParent
27+
sharedTrunkActiveThroughRow={false}
28+
sharedTrunkActiveBelowRow={false}
29+
ancestorTrunks={[]}
30+
connectorRailX={18}
31+
childStatusCenterX={26}
32+
isSelected={false}
33+
isElbowActive={false}
34+
>
35+
<div>row</div>
36+
</SubAgentListItem>
37+
);
38+
39+
const topSegment = view.getByTestId("subagent-connector-top-segment");
40+
const elbow = view.getByTestId("subagent-connector-elbow");
41+
42+
expect(topSegment.getAttribute("style")).toContain("left: 18px");
43+
expect(elbow.getAttribute("style")).toContain("left: 18px");
44+
expect(elbow.getAttribute("style")).toContain("width: 8px");
45+
expect(elbow.getAttribute("class")).toContain("border-l");
46+
});
47+
48+
test("supports connector elbows that bend back to the left", () => {
49+
const view = render(
50+
<SubAgentListItem
51+
connectorPosition="single"
52+
connectorStartsAtParent={false}
53+
sharedTrunkActiveThroughRow={false}
54+
sharedTrunkActiveBelowRow={false}
55+
ancestorTrunks={[]}
56+
connectorRailX={30}
57+
childStatusCenterX={26}
58+
isSelected={false}
59+
isElbowActive={false}
60+
>
61+
<div>row</div>
62+
</SubAgentListItem>
63+
);
64+
65+
const elbow = view.getByTestId("subagent-connector-elbow");
66+
67+
expect(elbow.getAttribute("style")).toContain("left: 26px");
68+
expect(elbow.getAttribute("style")).toContain("width: 4px");
69+
expect(elbow.getAttribute("class")).toContain("border-r");
70+
});
71+
});

0 commit comments

Comments
 (0)