Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ const lanesLifecycle = vi.hoisted(() => ({
const appStoreState = vi.hoisted(() => ({
projectHydrated: true,
showWelcome: false,
isNewTabOpen: false,
personalChatsTabOpen: false,
openNewTab: vi.fn(),
setPersonalChatsTabOpen: vi.fn(),
project: { rootPath: "/fake/project" },
projectBinding: {
kind: "local",
Expand Down Expand Up @@ -98,11 +102,43 @@ vi.mock("../../lib/dirtyWorkspaceBuffers", () => ({
getDirtyFileTextForWindow: vi.fn(),
}));

vi.mock("./AppShell", () => ({
AppShell: ({ children }: { children: React.ReactNode }) => (
<div data-testid="app-shell">{children}</div>
),
}));
vi.mock("./AppShell", async () => {
const ReactModule = await vi.importActual("react") as typeof ReactNamespace;
const Router = await vi.importActual("react-router-dom") as typeof RouterNamespace;

return {
AppShell: ({ children }: { children: React.ReactNode }) => {
const location = Router.useLocation();
const navigate = Router.useNavigate();
const isPersonalChatsRoute =
location.pathname === "/chats" || location.pathname.startsWith("/chats/");

ReactModule.useEffect(() => {
if (appStoreState.showWelcome && isPersonalChatsRoute) {
appStoreState.setPersonalChatsTabOpen(true);
}
}, [isPersonalChatsRoute]);

return (
<div data-testid="app-shell">
<button
type="button"
onClick={() => {
appStoreState.openNewTab();
navigate("/work");
}}
>
Open new tab
</button>
<button type="button" onClick={() => navigate("/chats")}>
Open chats
</button>
{children}
</div>
);
},
};
});

vi.mock("../onboarding/OnboardingBootstrap", () => ({
OnboardingBootstrap: () => null,
Expand Down Expand Up @@ -203,6 +239,17 @@ describe("App Work route keep-alive", () => {
lanesLifecycle.unmounts = 0;
appStoreState.projectHydrated = true;
appStoreState.showWelcome = false;
appStoreState.isNewTabOpen = false;
appStoreState.personalChatsTabOpen = false;
appStoreState.openNewTab.mockReset();
appStoreState.openNewTab.mockImplementation(() => {
appStoreState.isNewTabOpen = true;
appStoreState.showWelcome = true;
});
appStoreState.setPersonalChatsTabOpen.mockReset();
appStoreState.setPersonalChatsTabOpen.mockImplementation((open: boolean) => {
appStoreState.personalChatsTabOpen = open;
});
appStoreState.project = { rootPath: "/fake/project" };
appStoreState.projectBinding = {
kind: "local",
Expand Down Expand Up @@ -294,6 +341,32 @@ describe("App Work route keep-alive", () => {
expect(screen.queryByTestId("project-page")).toBeNull();
}, ROUTE_INTEGRATION_TIMEOUT_MS);

it("returns from projectless Chats to New Tab and reopens the existing Chats tab", async () => {
appStoreState.project = { rootPath: "" };
appStoreState.showWelcome = true;
window.history.replaceState({}, "", "/chats");
const { App } = await import("./App");

render(<App />);

expect((await screen.findByTestId("personal-chats-page")).getAttribute("data-standalone")).toBe("true");
await waitFor(() => {
expect(appStoreState.personalChatsTabOpen).toBe(true);
});

fireEvent.click(screen.getByRole("button", { name: "Open new tab" }));

await screen.findByTestId("project-page");
expect(appStoreState.openNewTab).toHaveBeenCalledOnce();
expect(appStoreState.isNewTabOpen).toBe(true);
expect(appStoreState.personalChatsTabOpen).toBe(true);

fireEvent.click(screen.getByRole("button", { name: "Open chats" }));

expect((await screen.findByTestId("personal-chats-page")).getAttribute("data-standalone")).toBe("true");
expect(appStoreState.personalChatsTabOpen).toBe(true);
}, ROUTE_INTEGRATION_TIMEOUT_MS);

it("parks the native Work browser view when the Work route is backgrounded", async () => {
const { App } = await import("./App");

Expand Down
16 changes: 14 additions & 2 deletions apps/desktop/src/renderer/components/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,9 @@ export function AppShell({ children }: { children: React.ReactNode }) {
const projectRevision = useAppStore((s) => s.projectRevision);
const setShowWelcome = useAppStore((s) => s.setShowWelcome);
const showWelcome = useAppStore((s) => s.showWelcome);
const setPersonalChatsTabOpen = useAppStore(
(s) => s.setPersonalChatsTabOpen,
);
const openRepo = useAppStore((s) => s.openRepo);
const switchProjectToPath = useAppStore((s) => s.switchProjectToPath);
const closeProject = useAppStore((s) => s.closeProject);
Expand Down Expand Up @@ -382,6 +385,8 @@ export function AppShell({ children }: { children: React.ReactNode }) {
const githubBannerDismissedRef = useRef(false);
githubBannerDismissedRef.current = githubBannerDismissed;
const isOnboardingRoute = location.pathname === "/onboarding";
const isPersonalChatsRoute =
location.pathname === "/chats" || location.pathname.startsWith("/chats/");
const isLanesRoute = location.pathname.startsWith("/lanes");
const isWorkRoute = location.pathname === "/work" || location.pathname.startsWith("/work/");
const isWorkAdjacentRoute = isWorkRoute || isLanesRoute;
Expand All @@ -395,6 +400,13 @@ export function AppShell({ children }: { children: React.ReactNode }) {
isLanesRouteRef.current = isLanesRoute;
}, [isLanesRoute]);

useEffect(() => {
// Any /chats visit opens the machine-level Chats tab — from the projectless
// shell AND from a project's sidebar link — so the top bar always carries a
// selectable affordance for the surface being shown.
if (isPersonalChatsRoute) setPersonalChatsTabOpen(true);
}, [isPersonalChatsRoute, setPersonalChatsTabOpen]);

useEffect(() => {
logRendererDebugEvent("renderer.route_change", {
pathname: location.pathname,
Expand Down Expand Up @@ -1235,8 +1247,8 @@ export function AppShell({ children }: { children: React.ReactNode }) {
>
<div className="shrink-0 relative z-20">
<TopBar
standaloneChatsActive={showWelcome && location.pathname === "/chats"}
onCloseStandaloneChats={() => navigate("/work", { replace: true })}
personalChatsRouteActive={isPersonalChatsRoute}
onNavigate={(path, opts) => navigate(path, opts)}
/>
</div>

Expand Down
74 changes: 74 additions & 0 deletions apps/desktop/src/renderer/components/app/ShellNavTab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { CSSProperties, ReactNode } from "react";
import { X } from "@phosphor-icons/react";

import { cn } from "../ui/cn";

type ShellNavTabProps = {
active: boolean;
label?: string;
onActivate?: () => void;
onClose: () => void;
closeTitle: string;
closeDisabled?: boolean;
children: ReactNode;
className?: string;
};

export function ShellNavTab({
active,
label,
onActivate,
onClose,
closeTitle,
closeDisabled = false,
children,
className,
}: ShellNavTabProps) {
return (
<div
role="button"
tabIndex={0}
aria-label={label}
className={cn(
"ade-shell-project-tab group inline-flex w-[clamp(128px,16vw,220px)] max-w-[220px] min-w-0 items-center gap-1.5 px-2.5",
"cursor-pointer font-semibold transition-[background-color,color,border-color,box-shadow] duration-150",
className,
)}
data-state={active ? "active" : undefined}
style={{ WebkitAppRegion: "no-drag" } as CSSProperties}
onClick={onActivate}
onKeyDown={(event) => {
if (
onActivate &&
(event.key === "Enter" || event.key === " ")
) {
event.preventDefault();
onActivate();
}
}}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
>
{children}
<button
type="button"
className={cn(
"ade-shell-control ml-auto inline-flex h-4 w-4 shrink-0 items-center justify-center text-current",
"opacity-0 transition-opacity duration-150 group-hover:opacity-100 group-focus-within:opacity-100",
)}
data-variant="ghost"
disabled={closeDisabled}
onClick={(event) => {
event.stopPropagation();
onClose();
}}
onKeyDown={(event) => {
// Enter/Space on the focused close button must not bubble into the
// wrapper's activate handler.
event.stopPropagation();
}}
title={closeTitle}
>
<X size={12} weight="regular" />
</button>
</div>
);
}
144 changes: 138 additions & 6 deletions apps/desktop/src/renderer/components/app/TopBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,9 @@ function resetStore() {
isNewTabOpen: false,
openNewTab: vi.fn(),
cancelNewTab: vi.fn(),
personalChatsTabOpen: false,
setPersonalChatsTabOpen: vi.fn(),
closePersonalChatsTab: vi.fn(),
projectTransition: null,
projectTransitionError: null,
openProjectTabRoots: [],
Expand Down Expand Up @@ -224,6 +227,33 @@ async function advancePhoneSyncStartupDelay() {

const resourceUsageMock = vi.fn();

function renderChatsTopBar({
personalChatsRouteActive,
storeOverrides = {},
}: {
personalChatsRouteActive: boolean;
storeOverrides?: Partial<ReturnType<typeof useAppStore.getState>>;
}) {
useAppStore.setState({
project: null,
projectHydrated: true,
showWelcome: true,
personalChatsTabOpen: true,
...storeOverrides,
} as any);
const onNavigate = vi.fn();

return {
onNavigate,
...render(
<TopBar
personalChatsRouteActive={personalChatsRouteActive}
onNavigate={onNavigate}
/>,
),
};
}

describe("TopBar", () => {
const originalAde = globalThis.window.ade;
const originalWebClientMode = globalThis.window.__adeWebClient;
Expand Down Expand Up @@ -358,16 +388,118 @@ describe("TopBar", () => {
});

it("shows a closable Chats pseudo-tab when chats are open without a project", () => {
useAppStore.setState({ project: null, projectHydrated: true, showWelcome: true } as any);
const onClose = vi.fn();

render(<TopBar standaloneChatsActive onCloseStandaloneChats={onClose} />);
const { onNavigate } = renderChatsTopBar({
personalChatsRouteActive: true,
});

expect(screen.getByText("Chats")).toBeTruthy();
const closeButton = screen.getByTitle("Close chats");
expect(onClose).not.toHaveBeenCalled();
expect(useAppStore.getState().closePersonalChatsTab).not.toHaveBeenCalled();
fireEvent.click(closeButton);
expect(onClose).toHaveBeenCalledOnce();
expect(useAppStore.getState().closePersonalChatsTab).toHaveBeenCalledOnce();
expect(onNavigate).toHaveBeenCalledWith("/work", { replace: true });
});

it("opens and activates New Tab from projectless Chats without closing Chats", () => {
const { onNavigate, rerender } = renderChatsTopBar({
personalChatsRouteActive: true,
});

fireEvent.click(screen.getByTitle("Open another project"));

expect(useAppStore.getState().openNewTab).toHaveBeenCalledOnce();
expect(onNavigate).toHaveBeenCalledWith("/work");

useAppStore.setState({ isNewTabOpen: true } as any);
rerender(<TopBar personalChatsRouteActive={false} onNavigate={onNavigate} />);

expect(screen.getByText("New Tab").parentElement?.getAttribute("data-state")).toBe("active");
expect(screen.getByText("Chats").parentElement?.getAttribute("data-state")).toBeNull();
});

it("navigates to an inactive Chats tab", () => {
const { onNavigate } = renderChatsTopBar({
personalChatsRouteActive: false,
storeOverrides: { isNewTabOpen: true },
});

fireEvent.click(screen.getByText("Chats").parentElement!);

expect(onNavigate).toHaveBeenCalledOnce();
expect(onNavigate).toHaveBeenCalledWith("/chats");
});

it("returns to Chats when the projectless New Tab is closed", () => {
const { onNavigate } = renderChatsTopBar({
personalChatsRouteActive: false,
storeOverrides: { isNewTabOpen: true },
});

fireEvent.click(screen.getByTitle("Close new tab"));

expect(useAppStore.getState().cancelNewTab).toHaveBeenCalledOnce();
expect(onNavigate).toHaveBeenCalledOnce();
expect(onNavigate).toHaveBeenCalledWith("/chats");
});

it("closes an inactive Chats tab without navigating away from New Tab", () => {
const { onNavigate } = renderChatsTopBar({
personalChatsRouteActive: false,
storeOverrides: { isNewTabOpen: true },
});

fireEvent.click(screen.getByTitle("Close chats"));

expect(useAppStore.getState().closePersonalChatsTab).toHaveBeenCalledOnce();
expect(useAppStore.getState().cancelNewTab).not.toHaveBeenCalled();
expect(onNavigate).not.toHaveBeenCalled();
});

it("does not activate a tab when the close button is pressed via keyboard", () => {
const { onNavigate } = renderChatsTopBar({
personalChatsRouteActive: false,
storeOverrides: { isNewTabOpen: true },
});

fireEvent.keyDown(screen.getByTitle("Close chats"), { key: "Enter" });

expect(onNavigate).not.toHaveBeenCalled();
});

it("routes back to the project when its tab is clicked while Chats is foreground", () => {
const root = "/Users/arul/ADE";
const { onNavigate } = renderChatsTopBar({
personalChatsRouteActive: true,
storeOverrides: {
project: { rootPath: root, name: "ADE" },
showWelcome: false,
openProjectTabRoots: [root],
projectInfoByRoot: { [root]: { rootPath: root, displayName: "ADE" } },
} as any,
});

fireEvent.click(screen.getByText("ADE", { selector: "span" }).closest('[role="button"]')!);

expect(onNavigate).toHaveBeenCalledWith("/work", { replace: true });
expect(useAppStore.getState().switchProjectToPath).not.toHaveBeenCalled();
});

it("drops the project tab's active styling while the Chats tab is the foreground surface", () => {
const root = "/Users/arul/ADE";
renderChatsTopBar({
personalChatsRouteActive: true,
storeOverrides: {
project: { rootPath: root, name: "ADE" },
showWelcome: false,
openProjectTabRoots: [root],
projectInfoByRoot: { [root]: { rootPath: root, displayName: "ADE" } },
} as any,
});

expect(screen.getByText("Chats").parentElement?.getAttribute("data-state")).toBe("active");
const projectTab = screen.getByText("ADE", { selector: "span" }).closest('[role="button"]');
expect(projectTab?.getAttribute("data-state")).toBeNull();
expect(projectTab?.getAttribute("aria-current")).toBe("true");
});

it("keeps the phone sync drawer open before a project is open", async () => {
Expand Down
Loading