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
10 changes: 10 additions & 0 deletions apps/app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
PROJECT_ARCHIVED_ROUTE_PATH,
PROJECTLESS_THREAD_DETAIL_ROUTE_PATH,
PROJECT_SETTINGS_ROUTE_PATH,
STANDALONE_APP_ROUTE_PATH,
THREAD_DETAIL_ROUTE_PATH,
} from "./lib/app-route-paths";

Expand All @@ -46,6 +47,11 @@ const InternalReplayListView = lazy(() =>
default: m.InternalReplayListView,
})),
);
const StandaloneAppView = lazy(() =>
import("./views/standalone-app/StandaloneAppView").then((m) => ({
default: m.StandaloneAppView,
})),
);

function AppRoutes() {
return (
Expand All @@ -54,6 +60,10 @@ function AppRoutes() {
<Routes>
<Route path={APP_ROOT_ROUTE_PATH} element={<RootComposeRoute />} />
<Route path={APP_SETTINGS_ROUTE_PATH} element={<AppSettingsView />} />
<Route
path={STANDALONE_APP_ROUTE_PATH}
element={<StandaloneAppView />}
/>
{import.meta.env.DEV ? (
<Route
path={DEVELOPMENT_REPLAY_ROUTE_PATH}
Expand Down
167 changes: 167 additions & 0 deletions apps/app/src/components/app-viewer/AppViewer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { useMemo } from "react";
import { useApp, useAppMarkdownPreview } from "@/hooks/queries/thread-queries";
import {
buildAppEntryUrl,
buildAppPublicBaseUrl,
} from "@/lib/file-content-urls";
import { createAssetMarkdownUrlTransform } from "@/lib/markdown-url-transform";
import { FilePreview as FilePreviewSurface } from "@/components/secondary-panel/FilePreview";

const APP_HEADER_MODE = "none";

export interface AppViewerProps {
applicationId: string;
/**
* Thread the app posts into via its `message` capability. `null` on the
* standalone surface, where the app renders without a host thread.
*/
targetThreadId: string | null;
}

/**
* Canonical renderer for a global app's entry. Resolves the app manifest, then
* serves the HTML entry through the injected `/api/v1/apps/:id/` iframe (the
* same route the in-thread tab uses) or renders a markdown entry statically.
* Shared by the in-thread `AppTabContent` panel and the standalone app route so
* there is a single app-rendering path.
*/
export function AppViewer({ applicationId, targetThreadId }: AppViewerProps) {
const appDetail = useApp(applicationId);
const markdownEntryPath =
appDetail.data?.entry.kind === "md" ? appDetail.data.entry.path : null;
const markdownPreview = useAppMarkdownPreview(
applicationId,
markdownEntryPath,
{
enabled: markdownEntryPath !== null,
},
);
const markdownAssetBaseUrl = useMemo(() => {
if (markdownEntryPath === null) {
return null;
}
return buildAppPublicBaseUrl(applicationId, markdownEntryPath);
}, [applicationId, markdownEntryPath]);
const markdownUrlTransform = useMemo(() => {
if (markdownAssetBaseUrl === null) {
return undefined;
}
return createAssetMarkdownUrlTransform(markdownAssetBaseUrl);
}, [markdownAssetBaseUrl]);
const htmlEntryUrl = useMemo(
() =>
buildAppEntryUrl({
applicationId,
targetThreadId,
reloadToken: appDetail.dataUpdatedAt,
}),
[appDetail.dataUpdatedAt, applicationId, targetThreadId],
);

if (appDetail.isError) {
return (
<FilePreviewSurface
path={applicationId}
headerMode={APP_HEADER_MODE}
state={{
kind: "error",
message:
appDetail.error instanceof Error
? appDetail.error.message
: "Failed to load app.",
}}
/>
);
}

if (!appDetail.data) {
return (
<FilePreviewSurface
path={applicationId}
headerMode={APP_HEADER_MODE}
state={{ kind: "loading" }}
/>
);
}

if (appDetail.data.entry.kind === "html") {
return (
<FilePreviewSurface
path={appDetail.data.name}
headerMode={APP_HEADER_MODE}
state={{
kind: "iframe",
sandbox: null,
title: appDetail.data.name,
url: htmlEntryUrl,
}}
/>
);
}

if (markdownPreview.isError) {
return (
<FilePreviewSurface
path={appDetail.data.name}
headerMode={APP_HEADER_MODE}
state={{
kind: "error",
message:
markdownPreview.error instanceof Error
? markdownPreview.error.message
: "Failed to load app entry.",
}}
/>
);
}

if (!markdownPreview.data) {
return (
<FilePreviewSurface
path={appDetail.data.name}
headerMode={APP_HEADER_MODE}
state={{ kind: "loading" }}
/>
);
}

if (markdownPreview.data.kind !== "text") {
return (
<FilePreviewSurface
path={appDetail.data.name}
headerMode={APP_HEADER_MODE}
state={{
kind: "error",
message: `Preview not available for ${markdownPreview.data.mimeType}.`,
}}
/>
);
}

if (markdownPreview.data.content.length === 0) {
return (
<FilePreviewSurface
path={appDetail.data.name}
headerMode={APP_HEADER_MODE}
state={{ kind: "empty" }}
/>
);
}

return (
<FilePreviewSurface
path={appDetail.data.name}
headerMode={APP_HEADER_MODE}
state={{
kind: "ready",
lineNumber: null,
showMarkdownModeToggle: false,
markdownUrlTransform,
file: {
name: markdownPreview.data.name ?? appDetail.data.entry.path,
contents: markdownPreview.data.content,
},
}}
/>
);
}
4 changes: 2 additions & 2 deletions apps/app/src/components/layout/AppLayout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -351,12 +351,12 @@ describe("AppLayout desktop chrome", () => {
await renderAppLayout({
desktopInfo: null,
initialEntry: "/projects/proj_sidebar_resize",
children: <iframe title="Status app" />,
children: <iframe title="Review Board app" />,
});

const appLayoutRoot = await screen.findByTestId("app-layout-root");
const resizeHandle = screen.getByTestId("app-sidebar-resize-handle");
const iframe = screen.getByTitle("Status app");
const iframe = screen.getByTitle("Review Board app");

expect(appLayoutRoot.contains(iframe)).toBe(true);
expect(screen.queryByTestId("iframe-drag-guard-overlay")).toBeNull();
Expand Down
31 changes: 29 additions & 2 deletions apps/app/src/components/layout/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
stripProjectThreads,
} from "@/hooks/queries/project-queries";
import {
useApp,
useThread,
useThreadDetailBootstrap,
} from "@/hooks/queries/thread-queries";
Expand Down Expand Up @@ -53,6 +54,7 @@ import { useQuickCreateProjectController } from "@/hooks/useQuickCreateProject";
import { useStandardManagerTimelinePreference } from "@/lib/manager-timeline-view-preference";
import { useSetRootComposeProjectId } from "@/lib/root-compose-selection";
import { IframeDragGuardOverlay } from "@/lib/iframe-drag-guard";
import { dispatchBrowserViewBoundsSync } from "@/lib/browser-view-bounds-sync";

const SIDEBAR_WIDTH_KEY = "bb.sidebar.width";
const SIDEBAR_OPEN_KEY = "bb.sidebar.open";
Expand Down Expand Up @@ -112,6 +114,7 @@ interface SidebarStateBridgeProps {
}

type SidebarResizeMouseEvent = ReactMouseEvent<HTMLDivElement>;
type SidebarOpenChangeHandler = (open: boolean) => void;

type SidebarProviderStyle = CSSProperties & {
"--sidebar-width": string;
Expand All @@ -124,14 +127,21 @@ function SidebarStateBridge({
children,
}: SidebarStateBridgeProps) {
const [open, setOpen] = useAtom(sidebarOpenAtom);
const handleOpenChange = useCallback<SidebarOpenChangeHandler>(
(nextOpen) => {
setOpen(nextOpen);
window.requestAnimationFrame(dispatchBrowserViewBoundsSync);
},
[setOpen],
);
return (
<SidebarProvider
ref={providerRef}
style={style}
className={className}
data-testid="app-layout-root"
open={open}
onOpenChange={setOpen}
onOpenChange={handleOpenChange}
>
{children}
</SidebarProvider>
Expand Down Expand Up @@ -340,6 +350,8 @@ export function AppLayout({ children }: AppLayoutProps) {
const {
projectId,
threadId,
applicationId,
isAppView,
isThreadView,
isArchivedView,
isSettingsView,
Expand Down Expand Up @@ -400,6 +412,11 @@ export function AppLayout({ children }: AppLayoutProps) {
: threadId
? `Thread ${threadId.slice(0, 8)}`
: "Thread";
// The standalone app route has no thread/project chrome, so the global header
// carries the app name (resolved from the manifest) the way it carries thread
// and project titles elsewhere.
const { data: app } = useApp(applicationId, { enabled: isAppView });
const appDisplayTitle = app?.name ?? "App";
useEffect(() => {
if (!thread?.projectId) return;
setRootComposeProjectId(thread.projectId);
Expand All @@ -409,7 +426,12 @@ export function AppLayout({ children }: AppLayoutProps) {
title: thread ? getThreadDisplayTitle(thread) : "Thread",
subtitle: undefined,
}
: isArchivedView && projectId
: isAppView
? {
title: appDisplayTitle,
subtitle: undefined,
}
: isArchivedView && projectId
? {
title: "",
subtitle: undefined,
Expand Down Expand Up @@ -444,6 +466,9 @@ export function AppLayout({ children }: AppLayoutProps) {
if (isThreadView) {
return threadDisplayTitle;
}
if (isAppView) {
return appDisplayTitle;
}
if (isArchivedView && projectId) {
return `${projectLabel ?? projectId} · Archived`;
}
Expand Down Expand Up @@ -479,6 +504,7 @@ export function AppLayout({ children }: AppLayoutProps) {
"--sidebar-width",
`${liveWidthRef.current}px`,
);
dispatchBrowserViewBoundsSync();
setSidebarWidth(liveWidthRef.current);
setIsSidebarResizing(false);
resetSidebarResizeDocumentState();
Expand All @@ -493,6 +519,7 @@ export function AppLayout({ children }: AppLayoutProps) {
"--sidebar-width",
`${liveWidthRef.current}px`,
);
dispatchBrowserViewBoundsSync();
};

const handleMouseMove = (event: MouseEvent) => {
Expand Down
Loading
Loading