diff --git a/cmd/sgai/webapp/happydom.ts b/cmd/sgai/webapp/happydom.ts index 0fb329d..526249c 100644 --- a/cmd/sgai/webapp/happydom.ts +++ b/cmd/sgai/webapp/happydom.ts @@ -1,2 +1,8 @@ +import { mock } from "bun:test"; import { GlobalRegistrator } from "@happy-dom/global-registrator"; + +mock.module("@/assets/sgai-logo.svg", () => ({ + default: "/assets/sgai-logo.svg", +})); + GlobalRegistrator.register(); diff --git a/cmd/sgai/webapp/src/components/WorkspaceRepositoryAction.tsx b/cmd/sgai/webapp/src/components/WorkspaceRepositoryAction.tsx index 330e938..82193f3 100644 --- a/cmd/sgai/webapp/src/components/WorkspaceRepositoryAction.tsx +++ b/cmd/sgai/webapp/src/components/WorkspaceRepositoryAction.tsx @@ -39,6 +39,12 @@ const repositoryActionIcons: Record = { delete: Trash2, }; +const treeTriggerGlyphs: Record = { + choose: "⋯", + detach: "⊘", + delete: "✕", +}; + function getConfirmOperation(action: ApiRepositoryAction): ApiRepositoryOperation | null { if (!action.defaultOperation) { return null; @@ -83,8 +89,8 @@ const TreeTrigger = forwardRef(function T className, ...props }, ref) { - const Icon = repositoryActionIcons[icon]; const isDestructive = tone === "destructive"; + const glyph = treeTriggerGlyphs[icon]; return ( ); }); diff --git a/cmd/sgai/webapp/src/components/__tests__/WorkspaceRepositoryAction.test.tsx b/cmd/sgai/webapp/src/components/__tests__/WorkspaceRepositoryAction.test.tsx index 420273d..bdcb201 100644 --- a/cmd/sgai/webapp/src/components/__tests__/WorkspaceRepositoryAction.test.tsx +++ b/cmd/sgai/webapp/src/components/__tests__/WorkspaceRepositoryAction.test.tsx @@ -323,6 +323,75 @@ describe("WorkspaceRepositoryAction", () => { await expectFocusRestoreAfterCancel("tree", "Choose action for fork demo-fork"); }); + it("renders tree detach triggers as text glyphs instead of svg icons", () => { + render( + , + ); + + const trigger = screen.getByRole("button", { name: "Detach demo-fork" }); + + expect(trigger.textContent?.trim()).toBe("⊘"); + expect(trigger.querySelector("svg")).toBeNull(); + }); + + it("renders chooser tree triggers as text glyphs instead of svg icons", () => { + render( + , + ); + + const trigger = screen.getByRole("button", { name: "Choose action for fork demo-fork" }); + + expect(trigger.textContent?.trim()).toBe("⋯"); + expect(trigger.querySelector("svg")).toBeNull(); + }); + + it("renders delete tree triggers as text glyphs instead of svg icons", () => { + render( + , + ); + + const trigger = screen.getByRole("button", { name: "Delete demo-fork" }); + + expect(trigger.textContent?.trim()).toBe("✕"); + expect(trigger.querySelector("svg")).toBeNull(); + }); + + it("keeps fork-row chooser triggers as svg icons", () => { + render( + , + ); + + const trigger = screen.getByRole("button", { name: "Choose action for fork demo-fork" }); + + expect(trigger.querySelector("svg")).toBeTruthy(); + }); + it("restores focus to the fork-row trigger after cancel", async () => { await expectFocusRestoreAfterCancel("fork-row", "Choose action for fork demo-fork"); }); diff --git a/cmd/sgai/webapp/src/pages/Dashboard.tsx b/cmd/sgai/webapp/src/pages/Dashboard.tsx index d1b58f6..1f5d8e5 100644 --- a/cmd/sgai/webapp/src/pages/Dashboard.tsx +++ b/cmd/sgai/webapp/src/pages/Dashboard.tsx @@ -20,7 +20,7 @@ import { SidebarTrigger, useSidebar, } from "@/components/ui/sidebar"; -import { Loader2, Inbox, Link as LinkIcon } from "lucide-react"; +import { Inbox, Link as LinkIcon } from "lucide-react"; import { WorkspaceRepositoryAction } from "@/components/WorkspaceRepositoryAction"; import { useFactoryState } from "@/lib/factory-state"; import { useSidebarResize } from "@/hooks/useSidebarResize"; @@ -36,6 +36,7 @@ import { resolveWorkspaceByName, } from "@/lib/workspace-identity"; import { sortByVisibleLabel } from "@/lib/workspace-sort"; +import type { ApiRepositoryOperation } from "@/types"; type ForkEntry = NonNullable[number]; type WorkspaceLabelSource = Pick & Partial>; @@ -152,50 +153,223 @@ interface WorkspaceIndicatorFields { running: boolean; needsInput: boolean; pinned: boolean; - external?: boolean; } interface WorkspaceIndicatorsProps { workspace: WorkspaceIndicatorFields; } +type WorkspaceIndicatorKey = keyof WorkspaceIndicatorFields; + +interface WorkspaceIndicatorDefinition { + key: WorkspaceIndicatorKey; + label: string; + activeToneClassName: string; + activeGlyph: string; + inactiveGlyph: string; +} + +interface WorkspaceIndicatorSlotProps { + active: boolean; + label: string; + activeToneClassName: string; + activeGlyph: string; + inactiveGlyph: string; +} + +const workspaceIndicatorDefinitions: readonly WorkspaceIndicatorDefinition[] = [ + { + key: "running", + label: "Running", + activeToneClassName: "text-emerald-600 dark:text-emerald-400", + activeGlyph: "▲", + inactiveGlyph: "△", + }, + { + key: "needsInput", + label: "Waiting for response", + activeToneClassName: "text-amber-600 dark:text-amber-400", + activeGlyph: "●", + inactiveGlyph: "○", + }, + { + key: "pinned", + label: "Pinned", + activeToneClassName: "text-primary", + activeGlyph: "■", + inactiveGlyph: "□", + }, +]; + +function WorkspaceIndicatorSlot({ + active, + label, + activeToneClassName, + activeGlyph, + inactiveGlyph, +}: WorkspaceIndicatorSlotProps) { + const stateLabel = `${label}: ${active ? "on" : "off"}`; + const glyph = active ? activeGlyph : inactiveGlyph; + const slot = ( + + {glyph} + + ); + + return ( + + {slot} + {stateLabel} + + ); +} + function WorkspaceIndicators({ workspace }: WorkspaceIndicatorsProps) { - const isActive = workspace.running; - const runningLabel = workspace.running ? "Running" : "In progress"; + const indicatorSummary = workspaceIndicatorDefinitions + .map(({ key, label }) => `${label}: ${workspace[key] ? "on" : "off"}`) + .join(", "); return ( - - {workspace.external && ( - - - - - External workspace - - )} - {isActive && ( - - )} - {workspace.needsInput && ( - - - - - Waiting for response - - )} - {workspace.pinned && ( - - - 📌 - - Pinned - - )} + + {workspaceIndicatorDefinitions.map(({ key, label, activeToneClassName, activeGlyph, inactiveGlyph }) => ( + + ))} ); } +function useTreeRowTooltipState() { + const [rowTooltipOpen, setRowTooltipOpen] = useState(false); + + const handleRowTooltipOpenChange = useCallback((nextOpen: boolean) => { + setRowTooltipOpen(nextOpen); + }, []); + + const handleLinkFocus = useCallback(() => { + setRowTooltipOpen(true); + }, []); + + const handleLinkBlur = useCallback(() => { + setRowTooltipOpen(false); + }, []); + + return { + rowTooltipOpen, + handleRowTooltipOpenChange, + handleLinkFocus, + handleLinkBlur, + }; +} + +interface WorkspaceTreeActionSlotProps { + workspace: Pick; + triggerLabelSuffix?: string; + onCompleted?: (operation: ApiRepositoryOperation) => void; +} + +interface WorkspaceTreeTrailingActionProps { + workspace?: Pick | null; + triggerLabelSuffix?: string; + onCompleted?: (operation: ApiRepositoryOperation) => void; +} + +function usesLeftTreeDetachSlot( + workspace: Pick, +): boolean { + const action = workspace.repositoryAction; + + return Boolean(action && action.entryPoint !== "hidden" && action.presentation.icon === "detach"); +} + +function usesTrailingTreeAction( + workspace: Pick, +): boolean { + const action = workspace.repositoryAction; + + return Boolean(action && action.entryPoint !== "hidden" && action.presentation.icon !== "detach"); +} + +function WorkspaceTreeActionSlot({ + workspace, + triggerLabelSuffix, + onCompleted, +}: WorkspaceTreeActionSlotProps) { + if (!usesLeftTreeDetachSlot(workspace)) { + return null; + } + + return ( + + + + ); +} + +function WorkspaceTreeTrailingRail({ children }: { children?: ReactNode }) { + return ( + + {children} + + ); +} + +function WorkspaceTreeTrailingAction({ + workspace, + triggerLabelSuffix, + onCompleted, +}: WorkspaceTreeTrailingActionProps) { + const showTrailingAction = Boolean(workspace && usesTrailingTreeAction(workspace)); + + return ( + + {showTrailingAction && workspace ? ( + + + + ) : null} + + ); +} + interface ForkItemProps { fork: ForkEntry; selectedWorkspace: ApiWorkspaceEntry | null; @@ -216,6 +390,7 @@ function ForkItem({ const forkFullEntry = workspaceLookup.get(fork.dir); const forkLabel = getVisibleForkLabel(fork, workspaceLookup, workspaceNameDisambiguators); const showTechnicalName = forkLabel !== fork.name; + const { rowTooltipOpen, handleRowTooltipOpenChange, handleLinkFocus, handleLinkBlur } = useTreeRowTooltipState(); const handleActionCompleted = useCallback(() => { if (isSameWorkspace(fork, selectedWorkspace) && rootWorkspace) { navigate(buildWorkspacePath(rootWorkspace, "forks")); @@ -225,7 +400,14 @@ function ForkItem({ return (
- + {forkFullEntry ? ( + + ) : null} + - - + + {forkLabel} + -
@@ -252,14 +438,11 @@ function ForkItem({
- {forkFullEntry ? ( - - ) : null} +
); @@ -302,6 +485,8 @@ function WorkspaceTreeItem({ const displayText = getVisibleWorkspaceLabel(workspace, workspaceLookup, workspaceNameDisambiguators); const showTechnicalName = displayText !== workspace.name; + const { rowTooltipOpen, handleRowTooltipOpenChange, handleLinkFocus, handleLinkBlur } = useTreeRowTooltipState(); + const treeActionWorkspace = fullWorkspace ?? workspace; const handleActionCompleted = useCallback(() => { if (isSameWorkspace(workspace, selectedWorkspace)) { navigate("/"); @@ -325,23 +510,32 @@ function WorkspaceTreeItem({ ) : ( )} - + + - - + + {displayText} + -
@@ -352,10 +546,9 @@ function WorkspaceTreeItem({
- @@ -397,37 +590,45 @@ function InProgressItem({ const fullWorkspace = workspaceLookup.get(workspace.dir); const displayText = getVisibleWorkspaceLabel(workspace, workspaceLookup, workspaceNameDisambiguators); const showTechnicalName = displayText !== workspace.name; + const { rowTooltipOpen, handleRowTooltipOpenChange, handleLinkFocus, handleLinkBlur } = useTreeRowTooltipState(); return ( - - - - - - {displayText} - - - - - - -
-
{displayText}
- {showTechnicalName && ( -
Name: {workspace.name}
+
+ + - - + > + + + + {displayText} + + + + + + +
+
{displayText}
+ {showTechnicalName && ( +
Name: {workspace.name}
+ )} +
+
+ + +
); } @@ -469,6 +670,8 @@ function PinnedTreeItem({ const displayText = getVisibleWorkspaceLabel(workspace, workspaceLookup, workspaceNameDisambiguators); const showTechnicalName = displayText !== workspace.name; + const { rowTooltipOpen, handleRowTooltipOpenChange, handleLinkFocus, handleLinkBlur } = useTreeRowTooltipState(); + const treeActionWorkspace = fullWorkspace ?? workspace; const handleActionCompleted = useCallback(() => { if (isSameWorkspace(workspace, selectedWorkspace)) { navigate("/"); @@ -492,23 +695,32 @@ function PinnedTreeItem({ ) : ( )} - + + - - + + {displayText} + -
@@ -519,10 +731,9 @@ function PinnedTreeItem({
-
@@ -574,6 +785,7 @@ function OrphanPinnedForkItem({ workspaceNameDisambiguators, ); const showTechnicalName = forkLabel !== fork.name; + const { rowTooltipOpen, handleRowTooltipOpenChange, handleLinkFocus, handleLinkBlur } = useTreeRowTooltipState(); const handleActionCompleted = useCallback(() => { if (isSameWorkspace(fork, selectedWorkspace)) { navigate(buildWorkspacePath(rootWorkspace, "forks")); @@ -584,7 +796,14 @@ function OrphanPinnedForkItem({
- + {forkFullEntry ? ( + + ) : null} + - - + + {displayLabel} + -
@@ -612,14 +835,11 @@ function OrphanPinnedForkItem({
- {forkFullEntry ? ( - - ) : null} +
); diff --git a/cmd/sgai/webapp/src/pages/__tests__/Dashboard.test.tsx b/cmd/sgai/webapp/src/pages/__tests__/Dashboard.test.tsx index 2321739..412127f 100644 --- a/cmd/sgai/webapp/src/pages/__tests__/Dashboard.test.tsx +++ b/cmd/sgai/webapp/src/pages/__tests__/Dashboard.test.tsx @@ -32,6 +32,17 @@ async function waitForForksRedirect(expectedPath: string) { }); } +function getTreeIndicatorSlot(link: HTMLElement, index: number) { + const indicatorGroup = within(link).getByRole("group"); + const slots = indicatorGroup.querySelectorAll("span"); + const slot = slots[index]; + if (!slot) { + throw new Error(`Expected tree indicator slot ${index}`); + } + + return slot; +} + const createRepositoryAction = (overrides: Record = {}) => ({ repositoryMode: "standalone", entryPoint: "confirm", @@ -344,6 +355,43 @@ function getMenuRowLabels(menu: Element): string[] { }); } +function getTreeRowByLabel(label: string): HTMLElement { + const links = screen.getAllByRole("link", { name: new RegExp(label, "i") }); + const link = links[0]; + + if (!link) { + throw new Error(`Expected tree link for ${label}`); + } + + const row = link.closest("li[data-sidebar='menu-item']"); + + if (!(row instanceof HTMLElement)) { + throw new Error(`Expected tree row for ${label}`); + } + + return row; +} + +function getTreeActionSlot(row: HTMLElement): HTMLElement { + const slot = row.querySelector('[data-slot="workspace-tree-action-slot"]'); + + if (!(slot instanceof HTMLElement)) { + throw new Error("Expected workspace tree action slot"); + } + + return slot; +} + +function getTreeTrailingRail(row: HTMLElement): HTMLElement { + const rail = row.querySelector('[data-slot="workspace-tree-trailing-action-rail"]'); + + if (!(rail instanceof HTMLElement)) { + throw new Error("Expected workspace tree trailing action rail"); + } + + return rail; +} + describe("Dashboard", () => { beforeEach(() => { mockDeleteWorkspace.mockClear(); @@ -399,13 +447,175 @@ describe("Dashboard", () => { }); }); - it("displays external workspace indicator", async () => { + it("does not display an external workspace indicator in the tree", async () => { + renderDashboard(); + + const workspaceLinks = await screen.findAllByRole("link", { name: /Needs Input Fallback Title/i }); + const workspaceLink = workspaceLinks[0]; + if (!workspaceLink) { + throw new Error("Expected needs-input workspace link"); + } + + expect(within(workspaceLink).queryByLabelText("External workspace")).toBeNull(); + }); + + it("renders a fixed-order unicode glyph rail for fully flagged workspaces", async () => { + mockWorkspaces = [createMockWorkspace({ + name: "indicator-workspace", + dir: "/path/to/indicator-workspace", + title: "Indicator Workspace", + running: true, + needsInput: true, + pinned: true, + external: true, + })]; + + renderDashboard(); + + const indicatorLinks = await screen.findAllByRole("link", { name: /Indicator Workspace/i }); + const indicatorLink = indicatorLinks[0]; + if (!indicatorLink) { + throw new Error("Expected indicator workspace link"); + } + + expect(indicatorLink.textContent).toContain("▲●■"); + expect(indicatorLink.querySelectorAll("svg").length).toBe(0); + expect(within(indicatorLink).getByRole("group", { name: "Running: on, Waiting for response: on, Pinned: on" })).toBeTruthy(); + expect(within(indicatorLink).getByLabelText("Running")).toBeTruthy(); + expect(within(indicatorLink).getByLabelText("Waiting for response")).toBeTruthy(); + expect(within(indicatorLink).getByLabelText("Pinned")).toBeTruthy(); + expect(within(indicatorLink).queryByLabelText("External workspace")).toBeNull(); + }); + + it("keeps pinned at the rightmost slot in the unicode glyph rail", async () => { + mockWorkspaces = [createMockWorkspace({ + name: "pinned-order-workspace", + dir: "/path/to/pinned-order-workspace", + title: "Pinned Order Workspace", + running: true, + needsInput: false, + pinned: true, + })]; + + renderDashboard(); + + const workspaceLinks = await screen.findAllByRole("link", { name: /Pinned Order Workspace/i }); + const workspaceLink = workspaceLinks[0]; + if (!workspaceLink) { + throw new Error("Expected pinned-order workspace link"); + } + + expect(workspaceLink.textContent).toContain("▲○■"); + expect(within(workspaceLink).getByRole("group", { name: "Running: on, Waiting for response: off, Pinned: on" })).toBeTruthy(); + expect(getTreeIndicatorSlot(workspaceLink, 0).textContent).toBe("▲"); + expect(getTreeIndicatorSlot(workspaceLink, 1).textContent).toBe("○"); + expect(getTreeIndicatorSlot(workspaceLink, 2).textContent).toBe("■"); + expect(getTreeIndicatorSlot(workspaceLink, 1).getAttribute("title")).toBeNull(); + }); + + it("renders the running-only unicode glyph rail state", async () => { + mockWorkspaces = [createMockWorkspace({ + name: "running-only-workspace", + dir: "/path/to/running-only-workspace", + title: "Running Only Workspace", + running: true, + needsInput: false, + pinned: false, + })]; + + renderDashboard(); + + const workspaceLinks = await screen.findAllByRole("link", { name: /Running Only Workspace/i }); + const workspaceLink = workspaceLinks[0]; + if (!workspaceLink) { + throw new Error("Expected running-only workspace link"); + } + + expect(workspaceLink.textContent).toContain("▲○□"); + expect(within(workspaceLink).getByRole("group", { name: "Running: on, Waiting for response: off, Pinned: off" })).toBeTruthy(); + expect(within(workspaceLink).getByLabelText("Running")).toBeTruthy(); + expect(within(workspaceLink).queryByLabelText("Waiting for response")).toBeNull(); + expect(within(workspaceLink).queryByLabelText("Pinned")).toBeNull(); + expect(getTreeIndicatorSlot(workspaceLink, 1).textContent).toBe("○"); + expect(getTreeIndicatorSlot(workspaceLink, 2).textContent).toBe("□"); + expect(getTreeIndicatorSlot(workspaceLink, 1).getAttribute("title")).toBeNull(); + expect(getTreeIndicatorSlot(workspaceLink, 2).getAttribute("title")).toBeNull(); + }); + + it("renders the pinned-only unicode glyph rail state with pinned rightmost", async () => { + mockWorkspaces = [createMockWorkspace({ + name: "pinned-only-workspace", + dir: "/path/to/pinned-only-workspace", + title: "Pinned Only Workspace", + running: false, + needsInput: false, + pinned: true, + })]; + renderDashboard(); + const workspaceLinks = await screen.findAllByRole("link", { name: /Pinned Only Workspace/i }); + const workspaceLink = workspaceLinks[0]; + if (!workspaceLink) { + throw new Error("Expected pinned-only workspace link"); + } + + expect(workspaceLink.textContent).toContain("△○■"); + expect(within(workspaceLink).getByRole("group", { name: "Running: off, Waiting for response: off, Pinned: on" })).toBeTruthy(); + expect(within(workspaceLink).queryByLabelText("Running")).toBeNull(); + expect(within(workspaceLink).queryByLabelText("Waiting for response")).toBeNull(); + expect(within(workspaceLink).getByLabelText("Pinned")).toBeTruthy(); + expect(getTreeIndicatorSlot(workspaceLink, 0).textContent).toBe("△"); + expect(getTreeIndicatorSlot(workspaceLink, 1).textContent).toBe("○"); + expect(getTreeIndicatorSlot(workspaceLink, 0).getAttribute("title")).toBeNull(); + expect(getTreeIndicatorSlot(workspaceLink, 1).getAttribute("title")).toBeNull(); + }); + + it("keeps inactive tree indicator slots visible for alignment", async () => { + renderDashboard(); + + const workspaceLinks = await screen.findAllByRole("link", { name: /Needs Input Fallback Title/i }); + const workspaceLink = workspaceLinks[0]; + if (!workspaceLink) { + throw new Error("Expected needs-input workspace link"); + } + + expect(workspaceLink.textContent).toContain("△●□"); + expect(within(workspaceLink).getByRole("group", { name: "Running: off, Waiting for response: on, Pinned: off" })).toBeTruthy(); + expect(within(workspaceLink).queryByLabelText("Running")).toBeNull(); + expect(within(workspaceLink).getByLabelText("Waiting for response")).toBeTruthy(); + expect(within(workspaceLink).queryByLabelText("Pinned")).toBeNull(); + expect(getTreeIndicatorSlot(workspaceLink, 0).textContent).toBe("△"); + expect(getTreeIndicatorSlot(workspaceLink, 2).textContent).toBe("□"); + expect(getTreeIndicatorSlot(workspaceLink, 0).getAttribute("title")).toBeNull(); + expect(getTreeIndicatorSlot(workspaceLink, 2).getAttribute("title")).toBeNull(); + }); + + it("shows a glyph tooltip without also opening the row tooltip", async () => { + const user = userEvent.setup(); + + mockWorkspaces = [createMockWorkspace({ + name: "glyph-hover-workspace", + dir: "/path/to/glyph-hover-workspace", + title: "Glyph Hover Workspace", + pinned: true, + })]; + + renderDashboard(); + + const workspaceLinks = await screen.findAllByRole("link", { name: /Glyph Hover Workspace/i }); + const workspaceLink = workspaceLinks[0]; + if (!workspaceLink) { + throw new Error("Expected glyph-hover workspace link"); + } + + await user.hover(within(workspaceLink).getByLabelText("Pinned")); + await waitFor(() => { - const externalIndicators = screen.queryAllByLabelText("External workspace"); - expect(externalIndicators.length).toBeGreaterThan(0); + expect(screen.getAllByText("Pinned: on").length).toBeGreaterThan(0); }); + + expect(screen.queryAllByText("Name: glyph-hover-workspace")).toHaveLength(0); }); it("shows running indicator for active workspaces", async () => { @@ -505,6 +715,43 @@ describe("Dashboard", () => { }); }); + it("uses an explicit right-side rail for trailing tree actions while keeping detach scoped to the left slot", async () => { + const user = userEvent.setup(); + renderDashboard(); + + await waitFor(() => { + expect(screen.getAllByText("Workspace One Title").length).toBeGreaterThan(0); + expect(screen.getAllByText("Workspace Two Title").length).toBeGreaterThan(0); + }); + + const actionRow = getTreeRowByLabel("Workspace One Title"); + const actionLink = within(actionRow).getByRole("link", { name: /Workspace One Title/i }); + const actionSlot = getTreeActionSlot(actionRow); + const actionTrailingRail = getTreeTrailingRail(actionRow); + + expect(within(actionSlot).getByRole("button", { name: "Detach workspace-1" })).toBeTruthy(); + expect(actionSlot.compareDocumentPosition(actionLink) & Node.DOCUMENT_POSITION_FOLLOWING).toBeGreaterThan(0); + expect(actionLink.compareDocumentPosition(actionTrailingRail) & Node.DOCUMENT_POSITION_FOLLOWING).toBeGreaterThan(0); + expect(within(actionTrailingRail).queryByRole("button")).toBeNull(); + + await user.click(screen.getByRole("button", { name: /expand forks for workspace two title/i })); + + await waitFor(() => { + expect(screen.getAllByText("Workspace Two Fork Title").length).toBeGreaterThan(0); + }); + + const forkRow = getTreeRowByLabel("Workspace Two Fork Title"); + const forkLink = within(forkRow).getByRole("link", { name: /Workspace Two Fork Title/i }); + const forkTrailingRail = getTreeTrailingRail(forkRow); + const forkActionButton = within(forkRow).getByRole("button", { name: "Choose action for fork workspace-2-fork-1" }); + + expect(forkRow.querySelector('[data-slot="workspace-tree-action-slot"]')).toBeNull(); + expect(forkLink.compareDocumentPosition(forkTrailingRail) & Node.DOCUMENT_POSITION_FOLLOWING).toBeGreaterThan(0); + expect(within(forkTrailingRail).getByRole("button", { name: "Choose action for fork workspace-2-fork-1" })).toBe(forkActionButton); + expect(forkActionButton.textContent?.trim()).toBe("⋯"); + expect(forkActionButton.querySelector("svg")).toBeNull(); + }); + it("opens detach confirmation dialog for detach-only workspaces", async () => { const user = userEvent.setup(); renderDashboard(); @@ -683,9 +930,9 @@ describe("Dashboard", () => { await waitFor(() => { const runningIndicator = screen.queryAllByLabelText("Running"); const needsInputIndicator = screen.queryAllByLabelText("Waiting for response"); - const externalIndicator = screen.queryAllByLabelText("External workspace"); + const pinnedIndicator = screen.queryAllByLabelText("Pinned"); - expect(runningIndicator.length + needsInputIndicator.length + externalIndicator.length).toBeGreaterThan(0); + expect(runningIndicator.length + needsInputIndicator.length + pinnedIndicator.length).toBeGreaterThan(0); }); }); }); @@ -861,13 +1108,16 @@ describe("Dashboard", () => { }); describe("external repository handling", () => { - it("displays external indicator for external repositories", async () => { + it("does not show an external tree indicator for external repositories", async () => { renderDashboard(); - await waitFor(() => { - const externalIndicators = screen.queryAllByLabelText("External workspace"); - expect(externalIndicators.length).toBeGreaterThan(0); - }); + const workspaceLinks = await screen.findAllByRole("link", { name: /Needs Input Fallback Title/i }); + const workspaceLink = workspaceLinks[0]; + if (!workspaceLink) { + throw new Error("Expected needs-input workspace link"); + } + + expect(within(workspaceLink).queryByLabelText("External workspace")).toBeNull(); }); it("uses detach copy for external repository removal affordances", async () => {