Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
13 changes: 11 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,10 @@ export function AppShell({ children }: { children: React.ReactNode }) {
isLanesRouteRef.current = isLanesRoute;
}, [isLanesRoute]);

useEffect(() => {
if (showWelcome && isPersonalChatsRoute) setPersonalChatsTabOpen(true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Open Chats when routed from a project

When a project is already open, the sidebar Chats link navigates to /chats without setting showWelcome; this effect is the only production path that marks personalChatsTabOpen true, so the Chats route renders but TopBar never creates the Chats tab and the project tab remains selected. This breaks the intended project+Chats tab coexistence whenever users open Chats from an active project; open the tab for any /chats route, not only welcome.

Useful? React with 👍 / 👎.

}, [showWelcome, isPersonalChatsRoute, setPersonalChatsTabOpen]);

useEffect(() => {
logRendererDebugEvent("renderer.route_change", {
pathname: location.pathname,
Expand Down Expand Up @@ -1235,8 +1244,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
69 changes: 69 additions & 0 deletions apps/desktop/src/renderer/components/app/ShellNavTab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
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();
}}
title={closeTitle}
>
<X size={12} weight="regular" />
</button>
</div>
);
}
97 changes: 91 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,71 @@ 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("keeps the phone sync drawer open before a project is open", async () => {
Expand Down
Loading