Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
23 changes: 1 addition & 22 deletions apps/desktop/src/renderer/components/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { ProjectSetupPage } from "../onboarding/ProjectSetupPage";
import { OnboardingBootstrap } from "../onboarding/OnboardingBootstrap";
import { GlossaryPage } from "../onboarding/GlossaryPage";
import { logRendererDebugEvent } from "../../lib/debugLog";
import { readStoredProjectRoute, writeStoredProjectRoute } from "./projectRouteStorage";
import { requestLinearIssueQuickView } from "../../lib/linearIssueQuickViewNavigation";

function createPreloadableRoute<TProps extends object>(
Expand Down Expand Up @@ -262,7 +263,6 @@ function isLanesRoutePath(pathname: string): boolean {
return pathname === "/lanes" || pathname.startsWith("/lanes/");
}

const PROJECT_ROUTE_STORAGE_PREFIX = "ade:project-route:";
const WARM_PROJECT_SURFACE_LIMIT = 8;
const EMPTY_PROJECT_TAB_ROOTS: string[] = [];
const EMPTY_PROJECT_INFO_BY_ROOT: Record<string, ProjectInfo> = {};
Expand All @@ -286,10 +286,6 @@ function bindingForProject(
return localProjectBindingForProject(project);
}

function projectRouteStorageKey(projectRoot: string): string {
return `${PROJECT_ROUTE_STORAGE_PREFIX}${projectRoot}`;
}

function serializeProjectRoute(location: ReturnType<typeof useLocation>): string | null {
const pathname = location.pathname || "/work";
const allowedRoots = [
Expand Down Expand Up @@ -323,23 +319,6 @@ function serializeStoredProjectRoute(location: ReturnType<typeof useLocation>):
return `${location.pathname}${search ? `?${search}` : ""}${location.hash ?? ""}`;
}

function readStoredProjectRoute(projectRoot: string): string | null {
try {
const value = window.localStorage.getItem(projectRouteStorageKey(projectRoot));
return value?.startsWith("/") ? value : null;
} catch {
return null;
}
}

function writeStoredProjectRoute(projectRoot: string, route: string): void {
try {
window.localStorage.setItem(projectRouteStorageKey(projectRoot), route);
} catch {
// localStorage can be unavailable in private/test environments.
}
}

type ProjectSurfaceEntry = {
surfaceKey: string;
project: ProjectInfo;
Expand Down
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>
);
}
Loading
Loading