diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx index cea9d82229..a94551711f 100644 --- a/apps/app/src/App.tsx +++ b/apps/app/src/App.tsx @@ -5,7 +5,7 @@ import { AuthCallbackView } from "./views/AuthCallbackView"; import { RootComposeRoute } from "./views/RootComposeView"; import { QuickCreateProjectProvider } from "./hooks/useQuickCreateProject"; import { ProviderCliHealthToasts } from "./components/provider-cli/ProviderCliHealthToasts"; -import { AppRouteNavigationProvider } from "./components/ui/app-route-anchor"; +import { RouteNavigationProvider } from "./components/ui/app-route-anchor"; import { useDesktopThemeSync } from "./hooks/useDesktopThemeSync"; import { useDesktopUpdateAvailableToast, @@ -14,7 +14,6 @@ import { import { useWebSocket } from "./hooks/useWebSocket"; import { APP_ROOT_ROUTE_PATH, - APP_SETTINGS_ROUTE_PATH, AUTOMATIONS_ROUTE_PATH, AUTH_CALLBACK_ROUTE_PATH, DEVELOPMENT_REPLAY_ROUTE_PATH, @@ -23,18 +22,18 @@ import { PROJECT_WORKFLOWS_ROUTE_PATH, PROJECTLESS_THREAD_DETAIL_ROUTE_PATH, PROJECT_SETTINGS_ROUTE_PATH, - STANDALONE_APP_ROUTE_PATH, + SETTINGS_ROUTE_PATH, THREAD_DETAIL_ROUTE_PATH, WORKFLOW_RUN_AGENT_ROUTE_PATH, WORKFLOW_RUN_ROUTE_PATH, -} from "./lib/app-route-paths"; +} from "./lib/route-paths"; const ThreadDetailRoute = lazy( () => import("./views/thread-detail/ThreadDetailRoute"), ); -const AppSettingsView = lazy(() => - import("./views/AppSettingsView").then((m) => ({ - default: m.AppSettingsView, +const SettingsView = lazy(() => + import("./views/SettingsView").then((m) => ({ + default: m.SettingsView, })), ); const AutomationsView = lazy(() => @@ -57,11 +56,6 @@ const InternalReplayListView = lazy(() => default: m.InternalReplayListView, })), ); -const StandaloneAppView = lazy(() => - import("./views/standalone-app/StandaloneAppView").then((m) => ({ - default: m.StandaloneAppView, - })), -); const WorkflowRunView = lazy(() => import("./views/workflow-run/WorkflowRunView").then((m) => ({ default: m.WorkflowRunView, @@ -79,12 +73,8 @@ function AppRoutes() { } /> - } /> + } /> } /> - } - /> {import.meta.env.DEV ? ( - + } /> - + ); } diff --git a/apps/app/src/components/app-viewer/AppViewer.tsx b/apps/app/src/components/app-viewer/AppViewer.tsx deleted file mode 100644 index 0d7c66d102..0000000000 --- a/apps/app/src/components/app-viewer/AppViewer.tsx +++ /dev/null @@ -1,173 +0,0 @@ -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 ( - - ); - } - - if (!appDetail.data) { - return ( - - ); - } - - if (appDetail.data.entry.kind === "html") { - // Keyed per app: an unkeyed iframe would be reused across apps via an - // in-place src swap, leaving the previous app's document visible under the - // loading state until the next app finishes loading. Within one app the - // key is stable, so reload-token changes still swap src in place (no blank - // flash on live reload). - return ( - - ); - } - - if (markdownPreview.isError) { - return ( - - ); - } - - if (!markdownPreview.data) { - return ( - - ); - } - - if (markdownPreview.data.kind !== "text") { - return ( - - ); - } - - if (markdownPreview.data.content.length === 0) { - return ( - - ); - } - - return ( - - ); -} diff --git a/apps/app/src/components/layout/AppLayout.tsx b/apps/app/src/components/layout/AppLayout.tsx index 365b85ef60..98ba866b2c 100644 --- a/apps/app/src/components/layout/AppLayout.tsx +++ b/apps/app/src/components/layout/AppLayout.tsx @@ -25,11 +25,10 @@ import { import { useExperiments } from "@/hooks/queries/system-queries"; import { useWorkflowRun } from "@/hooks/queries/workflow-queries"; import { - useApp, useThread, useThreadDetailBootstrap, } from "@/hooks/queries/thread-queries"; -import { useAppRoute } from "@/hooks/useAppRoute"; +import { useRouteState } from "@/hooks/useRouteState"; import { getThreadDisplayTitle } from "@/lib/thread-title"; import { applyResizeCursor, clearResizeCursor } from "@/lib/resizeCursor"; import { cn } from "@/lib/utils"; @@ -54,7 +53,7 @@ import { getProjectArchivedRoutePath, getProjectSettingsRoutePath, getProjectWorkflowsRoutePath, -} from "@/lib/app-route-paths"; +} from "@/lib/route-paths"; import { useQuickCreateProjectController } from "@/hooks/useQuickCreateProject"; import { useSetRootComposeProjectId } from "@/lib/root-compose-selection"; import { IframeDragGuardOverlay } from "@/lib/iframe-drag-guard"; @@ -393,16 +392,14 @@ export function AppLayout({ children }: AppLayoutProps) { const { projectId, threadId, - applicationId, workflowRunId, - isAppView, isThreadView, isArchivedView, isWorkflowsView, isWorkflowRunView, isSettingsView, isRootView, - } = useAppRoute(); + } = useRouteState(); const sidebarNavigationQuery = useSidebarNavigation(); const projects = useMemo( () => sidebarNavigationQuery.data?.projects.map(stripProjectThreads), @@ -446,11 +443,6 @@ 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"; // The run page route is projectless, so the run row (shared query with the // page itself) is the only synchronous source for the document title. const { data: workflowRun } = useWorkflowRun( @@ -465,12 +457,7 @@ export function AppLayout({ children }: AppLayoutProps) { title: thread ? getThreadDisplayTitle(thread) : "Thread", subtitle: undefined, } - : isAppView - ? { - title: appDisplayTitle, - subtitle: undefined, - } - : isArchivedView && projectId + : isArchivedView && projectId ? { title: "", subtitle: undefined, @@ -517,9 +504,6 @@ export function AppLayout({ children }: AppLayoutProps) { if (isThreadView) { return threadDisplayTitle; } - if (isAppView) { - return appDisplayTitle; - } if (isArchivedView && projectId) { return `${projectLabel ?? projectId} · Archived`; } diff --git a/apps/app/src/components/project/ProjectActionsMenu.tsx b/apps/app/src/components/project/ProjectActionsMenu.tsx index 754738c017..36e664907d 100644 --- a/apps/app/src/components/project/ProjectActionsMenu.tsx +++ b/apps/app/src/components/project/ProjectActionsMenu.tsx @@ -23,7 +23,7 @@ import { usePathPickerHost } from "@/hooks/useLocalPathPicker"; import { getProjectArchivedRoutePath, getProjectSettingsRoutePath, -} from "@/lib/app-route-paths"; +} from "@/lib/route-paths"; import { cn } from "@/lib/utils"; import { useProjectActions } from "./ProjectActionsProvider"; diff --git a/apps/app/src/components/project/ProjectActionsProvider.tsx b/apps/app/src/components/project/ProjectActionsProvider.tsx index 8a67010ebc..4c7a485284 100644 --- a/apps/app/src/components/project/ProjectActionsProvider.tsx +++ b/apps/app/src/components/project/ProjectActionsProvider.tsx @@ -8,7 +8,7 @@ import { import { useSetAtom } from "jotai"; import { useNavigate } from "react-router-dom"; import type { ProjectResponse } from "@bb/server-contract"; -import { useAppRoute } from "@/hooks/useAppRoute"; +import { useRouteState } from "@/hooks/useRouteState"; import { useAddLocalProjectSource, useDeleteProject, @@ -29,7 +29,7 @@ import { type ProjectRenameDialogTarget, } from "@/components/dialogs/ProjectRenameDialog"; import { collapsedProjectIdsAtom } from "@/components/sidebar/sidebarCollapsedAtoms"; -import { getRootComposeRoutePath } from "@/lib/app-route-paths"; +import { getRootComposeRoutePath } from "@/lib/route-paths"; export interface ProjectActionsContextValue { requestRename: (project: ProjectResponse) => void; @@ -59,7 +59,7 @@ export function ProjectActionsProvider({ children, }: ProjectActionsProviderProps) { const navigate = useNavigate(); - const { projectId: routeProjectId } = useAppRoute(); + const { projectId: routeProjectId } = useRouteState(); const setCollapsedProjectIdList = useSetAtom(collapsedProjectIdsAtom); const updateProject = useUpdateProject(); const deleteProject = useDeleteProject(); diff --git a/apps/app/src/components/right-panel/ThreadSecondaryPanelNewTab.stories.tsx b/apps/app/src/components/right-panel/ThreadSecondaryPanelNewTab.stories.tsx index 036d8f13bf..9db4bde925 100644 --- a/apps/app/src/components/right-panel/ThreadSecondaryPanelNewTab.stories.tsx +++ b/apps/app/src/components/right-panel/ThreadSecondaryPanelNewTab.stories.tsx @@ -1,7 +1,6 @@ import { useCallback, useMemo, useState, type ReactNode } from "react"; import { QueryClientProvider } from "@tanstack/react-query"; import type { - AppSummary, ThreadStoragePathListResponse, WorkspacePathEntry, WorkspacePathListResponse, @@ -10,7 +9,6 @@ import { StoryCard, StoryRow } from "../../../.ladle/story-card"; import { WithDesktopBrowser } from "../../../.ladle/story-desktop"; import { createAppQueryClient } from "@/lib/query-client"; import { - appsQueryKey, environmentPathsQueryKey, threadStoragePathsQueryKey, } from "@/hooks/queries/query-keys"; @@ -41,7 +39,7 @@ const PROJECT_ID = "proj_bb"; const ENVIRONMENT_ID = "env_open_file_story"; const STORY_SOURCE_LIMIT = 40; const BLANK_THREAD_ID = "thr_new_tab_blank_story"; -const APPS_THREAD_ID = "thr_new_tab_apps_story"; +const RECENTS_THREAD_ID = "thr_new_tab_recents_story"; const LONG_RECENTS_THREAD_ID = "thr_new_tab_long_recents_story"; const SEARCH_THREAD_ID = "thr_new_tab_search_story"; const STORY_TERMINAL_ID = "term_new_tab_story"; @@ -99,84 +97,6 @@ const THREAD_STORAGE_PATH_RESULTS: WorkspacePathEntry[] = [ }, ]; -const APPS_RESPONSE: AppSummary[] = [ - { - applicationId: "story-review-board", - name: "Review Board", - entry: { path: "index.html", kind: "html" }, - capabilities: ["data", "message"], - icon: { kind: "builtin", name: "ListTodo" }, - source: null, - }, -]; - -const APPS_ROW_APPS: AppSummary[] = [ - { - applicationId: "app_status", - name: "Status", - entry: { path: "index.html", kind: "html" }, - capabilities: ["data", "message"], - icon: { kind: "builtin", name: "ListTodo" }, - source: null, - }, - { - applicationId: "app_workspace_map", - name: "Workspace Map", - entry: { path: "index.html", kind: "html" }, - capabilities: ["data"], - icon: { kind: "builtin", name: "GridView" }, - source: null, - }, - { - applicationId: "app_release_notes", - name: "Release Notes", - entry: { path: "index.html", kind: "html" }, - capabilities: ["message"], - icon: { kind: "builtin", name: "File" }, - source: null, - }, - { - applicationId: "app_session_notes", - name: "Session Notes", - entry: { path: "index.html", kind: "html" }, - capabilities: ["data", "message"], - icon: { kind: "builtin", name: "File" }, - source: null, - }, - { - applicationId: "app_error_dashboard", - name: "Error Dashboard", - entry: { path: "index.html", kind: "html" }, - capabilities: ["data"], - icon: { kind: "builtin", name: "AlertCircle" }, - source: null, - }, - { - applicationId: "app_release_tracker", - name: "Release Tracker", - entry: { path: "index.html", kind: "html" }, - capabilities: ["message"], - icon: { kind: "builtin", name: "GitBranch" }, - source: null, - }, - { - applicationId: "app_prompt_library", - name: "Prompt Library", - entry: { path: "index.html", kind: "html" }, - capabilities: ["data", "message"], - icon: { kind: "builtin", name: "GridView" }, - source: null, - }, - { - applicationId: "app_qa_checklist", - name: "QA Checklist", - entry: { path: "index.html", kind: "html" }, - capabilities: ["data"], - icon: { kind: "builtin", name: "ListTodo" }, - source: null, - }, -]; - const RECENT_ROW_ITEMS: ThreadRecentItem[] = [ { source: "thread-storage", @@ -278,7 +198,6 @@ interface PanelStageProps { } interface NewTabPanelStoryProps { - apps: readonly AppSummary[]; currentThreadId: string; initialQuery: string; projectId: string | undefined; @@ -315,14 +234,6 @@ function createStoryActiveTab( } const { selection } = outcome; - if (selection.source === "app") { - return { - applicationId: selection.applicationId, - id: `app:${selection.applicationId}`, - kind: "app", - }; - } - if (selection.source === "workspace") { return { environmentId: ENVIRONMENT_ID, @@ -345,7 +256,6 @@ function createStoryActiveTab( } interface StoryQueryClientArgs { - apps: readonly AppSummary[]; currentThreadId: string; initialQuery: string; threadStoragePaths: readonly WorkspacePathEntry[]; @@ -360,7 +270,6 @@ interface SeedThreadRecentItemsArgs { interface SeededNewTabPageProps { currentThreadId: string; initialQuery: string; - onCreateAppPromptPrefill: () => void; onOpenBrowser?: () => void; onSelect: (selection: FileSearchSelection) => void; onStartTerminal: () => void; @@ -415,7 +324,6 @@ function seedThreadRecentItems({ } function useStoryQueryClient({ - apps, currentThreadId, initialQuery, threadStoragePaths, @@ -445,7 +353,6 @@ function useStoryQueryClient({ ), makeWorkspacePathResponse(workspacePaths), ); - queryClient.setQueryData(appsQueryKey(), apps); queryClient.setQueryData( threadStoragePathsQueryKey(currentThreadId, { limit: STORY_SOURCE_LIMIT, @@ -457,7 +364,6 @@ function useStoryQueryClient({ ); return queryClient; }, [ - apps, currentThreadId, initialQuery, threadStoragePaths, @@ -468,7 +374,6 @@ function useStoryQueryClient({ function SeededNewTabPage({ currentThreadId, initialQuery, - onCreateAppPromptPrefill, onOpenBrowser, onSelect, onStartTerminal, @@ -486,7 +391,6 @@ function SeededNewTabPage({ focusRequest={0} initialQuery={initialQuery} onSelect={onSelect} - onCreateAppPromptPrefill={onCreateAppPromptPrefill} onOpenBrowser={onOpenBrowser} onStartTerminal={onStartTerminal} /> @@ -494,7 +398,6 @@ function SeededNewTabPage({ } function NewTabPanelStory({ - apps, currentThreadId, initialQuery, projectId, @@ -505,7 +408,6 @@ function NewTabPanelStory({ }: NewTabPanelStoryProps) { const [outcome, setOutcome] = useState(null); const queryClient = useStoryQueryClient({ - apps, currentThreadId, initialQuery, threadStoragePaths, @@ -562,22 +464,13 @@ function NewTabPanelStory({ const { selection } = outcome; return [ { - id: - selection.source === "app" - ? `app:${selection.applicationId}` - : `${selection.source}:${selection.path}`, + id: `${selection.source}:${selection.path}`, filename: - selection.source === "app" - ? selection.applicationId - : getFileNameFromPath({ path: selection.path }), + getFileNameFromPath({ path: selection.path }), isActive: true, leadingVisual: ( @@ -596,7 +489,6 @@ function NewTabPanelStory({ initialQuery={initialQuery} projectId={projectId} recentItems={recentItems} - onCreateAppPromptPrefill={noop} onOpenBrowser={showOpenBrowser ? handleOpenBrowser : undefined} onSelect={handleSelect} onStartTerminal={handleStartTerminal} @@ -621,16 +513,12 @@ function NewTabPanelStory({

Selected{" "} - {outcome.selection.source === "app" - ? "app" - : outcome.selection.source === "workspace" + {outcome.selection.source === "workspace" ? "workspace file" : "thread storage file"}

- {outcome.selection.source === "app" - ? outcome.selection.applicationId - : outcome.selection.path} + {outcome.selection.path}

); @@ -668,10 +556,9 @@ export function NewTab() { - ); - } - - return ( - - ); -} diff --git a/apps/app/src/components/secondary-panel/AppTabContent.test.tsx b/apps/app/src/components/secondary-panel/AppTabContent.test.tsx deleted file mode 100644 index ff4f5aea5b..0000000000 --- a/apps/app/src/components/secondary-panel/AppTabContent.test.tsx +++ /dev/null @@ -1,126 +0,0 @@ -// @vitest-environment jsdom - -import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; -import type { AppDetail } from "@bb/server-contract"; -import * as api from "@/lib/api"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { appQueryKey } from "@/hooks/queries/query-keys"; -import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; -import { AppTabContent } from "./AppTabContent"; - -vi.mock("@/lib/api", async (importOriginal) => { - const actual = await importOriginal(); - - return { - ...actual, - getApp: vi.fn(), - getAppMarkdownPreview: vi.fn(), - }; -}); - -const HTML_APP: AppDetail = { - applicationId: "status", - name: "Review Board", - entry: { path: "index.html", kind: "html" }, - capabilities: ["data", "message"], - icon: { kind: "builtin", name: "ListTodo" }, - source: null, - appsRootPath: "/tmp/bb-data/apps", - appRootPath: "/tmp/bb-data/apps/status", - appDataPath: "/tmp/bb-data/apps/status/data", -}; - -const MARKDOWN_APP: AppDetail = { - applicationId: "readme", - name: "Readme", - entry: { path: "docs/index.md", kind: "md" }, - capabilities: [], - icon: { kind: "builtin", name: "GridView" }, - source: null, - appsRootPath: "/tmp/bb-data/apps", - appRootPath: "/tmp/bb-data/apps/readme", - appDataPath: "/tmp/bb-data/apps/readme/data", -}; - -afterEach(() => { - cleanup(); - vi.clearAllMocks(); - vi.useRealTimers(); -}); - -describe("AppTabContent", () => { - it("renders HTML apps in the injected app iframe route", async () => { - vi.mocked(api.getApp).mockResolvedValue(HTML_APP); - const { wrapper } = createQueryClientTestHarness(); - - render(, { - wrapper, - }); - - const frame = await screen.findByTitle("Review Board"); - expect(frame.getAttribute("src")).toMatch( - /^\/api\/v1\/apps\/status\/\?targetThreadId=thr_1&v=\d+$/u, - ); - expect(frame.getAttribute("sandbox")).toBeNull(); - expect(api.getAppMarkdownPreview).not.toHaveBeenCalled(); - }); - - it("reloads HTML app iframes when app detail data refreshes", async () => { - vi.mocked(api.getApp).mockReturnValue(new Promise(() => {})); - const { queryClient, wrapper } = createQueryClientTestHarness(); - const queryKey = appQueryKey("status"); - queryClient.setQueryData(queryKey, HTML_APP, { - updatedAt: 1_000, - }); - - render(, { - wrapper, - }); - - const firstFrame = screen.getByTitle("Review Board"); - const firstSrc = firstFrame.getAttribute("src"); - act(() => { - queryClient.setQueryData( - queryKey, - { - ...HTML_APP, - name: "Review Board", - }, - { - updatedAt: 2_000, - }, - ); - }); - - await waitFor(() => { - expect(screen.getByTitle("Review Board").getAttribute("src")).not.toBe( - firstSrc, - ); - }); - }); - - it("renders markdown apps through the static markdown preview path", async () => { - vi.mocked(api.getApp).mockResolvedValue(MARKDOWN_APP); - vi.mocked(api.getAppMarkdownPreview).mockResolvedValue({ - kind: "text", - path: "docs/index.md", - name: "index.md", - url: "/api/v1/apps/readme/docs/index.md", - mimeType: "text/markdown", - content: "# App Notes\n\nStatic content.", - }); - const { wrapper } = createQueryClientTestHarness(); - - render(, { - wrapper, - }); - - expect(await screen.findByText("App Notes")).toBeTruthy(); - expect(screen.getByText("Static content.")).toBeTruthy(); - expect(api.getAppMarkdownPreview).toHaveBeenCalledWith( - "readme", - "docs/index.md", - expect.any(AbortSignal), - ); - }); -}); diff --git a/apps/app/src/components/secondary-panel/AppTabContent.tsx b/apps/app/src/components/secondary-panel/AppTabContent.tsx deleted file mode 100644 index a5ed9c7ef3..0000000000 --- a/apps/app/src/components/secondary-panel/AppTabContent.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { AppViewer } from "@/components/app-viewer/AppViewer"; - -export interface AppTabContentProps { - applicationId: string; - threadId: string; -} - -/** - * In-thread secondary-panel host for a global app. Delegates to the shared - * {@link AppViewer}, targeting the panel's thread so the app's `message` - * capability posts into that thread. - */ -export function AppTabContent({ applicationId, threadId }: AppTabContentProps) { - return ; -} diff --git a/apps/app/src/components/secondary-panel/FilePreview.test.tsx b/apps/app/src/components/secondary-panel/FilePreview.test.tsx index da77dd1e8d..96dd1b9bad 100644 --- a/apps/app/src/components/secondary-panel/FilePreview.test.tsx +++ b/apps/app/src/components/secondary-panel/FilePreview.test.tsx @@ -130,17 +130,17 @@ afterEach(() => { }); describe("FilePreview", () => { - it("delays the iframe loading indicator so fast app switches do not flash it", () => { + it("delays the iframe loading indicator so fast iframe switches do not flash it", () => { vi.useFakeTimers(); const { container } = render( , ); @@ -162,18 +162,18 @@ describe("FilePreview", () => { vi.useFakeTimers(); const { container } = render( , ); - fireEvent.load(screen.getByTitle("Review Board")); + fireEvent.load(screen.getByTitle("Preview")); act(() => { vi.advanceTimersByTime(160); }); diff --git a/apps/app/src/components/secondary-panel/NewTabFileSearch.test.tsx b/apps/app/src/components/secondary-panel/NewTabFileSearch.test.tsx index 96fc375d54..e50a98b937 100644 --- a/apps/app/src/components/secondary-panel/NewTabFileSearch.test.tsx +++ b/apps/app/src/components/secondary-panel/NewTabFileSearch.test.tsx @@ -11,13 +11,12 @@ import { import { createStore, Provider } from "jotai"; import { createElement, type ReactNode } from "react"; import type { - AppSearchSuggestion, FilePathSearchSuggestion, FileSearchSuggestion, UseFileSearchSuggestionsArgs, UseFileSearchSuggestionsResult, } from "@/hooks/useFileSearchSuggestions"; -import type { AppSummary, BbDesktopInfo } from "@bb/server-contract"; +import type { BbDesktopInfo } from "@bb/server-contract"; import { PERSONAL_PROJECT_ID } from "@bb/domain"; import { NewTabActions, @@ -27,8 +26,6 @@ import { } from "./NewTabFileSearch"; import { getThreadRecentItemsStorageKey } from "./threadRecentItems"; import { createBbDesktopApi } from "@/test/bb-desktop-test-utils"; -import { CHROME_SECTION_LABEL_CLASS } from "@/components/ui/chromeStyleTokens"; -import type { PromptDraftState } from "@/lib/prompt-draft"; interface ProviderWrapperProps { children: ReactNode; @@ -43,31 +40,15 @@ interface RenderLauncherArgs { } interface RenderActionsArgs { - projectId?: string; - currentThreadId?: string; - onSelect?: NewTabActionsProps["onSelect"]; - onCreateAppPromptPrefill?: NewTabActionsProps["onCreateAppPromptPrefill"]; onOpenBrowser?: NewTabActionsProps["onOpenBrowser"]; onStartTerminal?: NewTabActionsProps["onStartTerminal"]; } type FileSearchMockState = UseFileSearchSuggestionsResult; -interface AppsQueryMockState { - data: AppSummary[] | undefined; - isLoading: boolean; - isError: boolean; -} - -interface PromptDraftMockState { - currentDraft: PromptDraftState; - setDrafts: PromptDraftState[]; -} - const fileSearchMockState = vi.hoisted(() => ({ suggestions: [], isLoading: false, - appsError: false, fileSearchError: false, isDebouncing: false, isUnavailable: false, @@ -75,17 +56,6 @@ const fileSearchMockState = vi.hoisted(() => ({ const fileSearchMockArgs = vi.hoisted(() => []); -const appsQueryMockState = vi.hoisted(() => ({ - data: [], - isLoading: false, - isError: false, -})); - -const promptDraftMockState = vi.hoisted(() => ({ - currentDraft: { text: "", mentions: [], attachments: [] }, - setDrafts: [], -})); - // The launcher's data sources are the only external boundary here; stub them so // the test focuses on the menu/search split and desktop Browser gating. vi.mock("@/hooks/useFileSearchSuggestions", () => ({ @@ -95,20 +65,6 @@ vi.mock("@/hooks/useFileSearchSuggestions", () => ({ }, })); -vi.mock("@/hooks/queries/thread-queries", () => ({ - useApps: () => appsQueryMockState, -})); - -vi.mock("@/hooks/usePromptDraftStorage", () => ({ - usePromptDraftStorage: () => ({ - storageKey: "draft-key", - getCurrent: () => promptDraftMockState.currentDraft, - setDraft: (draft: PromptDraftState) => { - promptDraftMockState.setDrafts.push(draft); - }, - }), -})); - const DESKTOP_INFO: BbDesktopInfo = { lastCheckedAt: null, latestVersion: null, @@ -119,22 +75,6 @@ const DESKTOP_INFO: BbDesktopInfo = { version: "0.0.1", }; -const APP_SUGGESTION = { - source: "app", - entryKind: "app", - app: { - applicationId: "status", - name: "Review Board", - entry: { path: "index.html", kind: "html" }, - capabilities: ["data", "message"], - icon: { kind: "builtin", name: "ListTodo" }, - source: null, - }, - applicationId: "status", - name: "Review Board", - score: 90, -} satisfies AppSearchSuggestion; - const FILE_SUGGESTION = { source: "workspace", entryKind: "file", @@ -153,58 +93,15 @@ const REPORT_FILE_SUGGESTION = { positions: [], } satisfies FilePathSearchSuggestion; -const DRAFT_WITH_ATTACHMENT = { - text: "Keep this draft", - mentions: [], - attachments: [ - { - type: "localFile", - path: "/tmp/spec.md", - name: "spec.md", - sizeBytes: 42, - mimeType: "text/markdown", - }, - ], -} satisfies PromptDraftState; - -function makeAppSummary(index: number): AppSummary { - const applicationId = `app-${index}`; - return { - ...APP_SUGGESTION.app, - applicationId, - name: `App ${index}`, - } satisfies AppSummary; -} - function resetFileSearchMockState(): void { fileSearchMockState.suggestions = []; fileSearchMockState.isLoading = false; - fileSearchMockState.appsError = false; fileSearchMockState.fileSearchError = false; fileSearchMockState.isDebouncing = false; fileSearchMockState.isUnavailable = false; fileSearchMockArgs.length = 0; } -function resetAppsQueryMockState(): void { - appsQueryMockState.data = []; - appsQueryMockState.isLoading = false; - appsQueryMockState.isError = false; -} - -function resetPromptDraftMockState(): void { - promptDraftMockState.currentDraft = { - text: "", - mentions: [], - attachments: [], - }; - promptDraftMockState.setDrafts = []; -} - -function setAppSummaries(apps: readonly AppSummary[]): void { - appsQueryMockState.data = [...apps]; -} - function setFileSearchSuggestions( suggestions: readonly FileSearchSuggestion[], ): void { @@ -234,10 +131,6 @@ function renderActions(args: RenderActionsArgs = {}) { createElement(Provider, { store }, children); return render( createElement(NewTabActions, { - projectId: args.projectId ?? "proj_1", - currentThreadId: args.currentThreadId ?? "thr_1", - onSelect: args.onSelect ?? vi.fn(), - onCreateAppPromptPrefill: args.onCreateAppPromptPrefill, onOpenBrowser: args.onOpenBrowser, onStartTerminal: args.onStartTerminal, }), @@ -250,172 +143,24 @@ afterEach(() => { delete window.bbDesktop; localStorage.clear(); resetFileSearchMockState(); - resetAppsQueryMockState(); - resetPromptDraftMockState(); }); describe("NewTabActions", () => { - it("does not render the old persistent apps-and-files search input", () => { + it("does not render the old persistent search input", () => { renderActions(); - expect( - screen.queryByRole("textbox", { name: "Search apps and files" }), - ).toBeNull(); - }); - - it("shows the Apps header when Create App is the only app-related row", () => { - renderActions(); - - expect(screen.getByRole("button", { name: "Create App..." })).toBeTruthy(); - expect(screen.getByText("Apps")).toBeTruthy(); - }); - - it("shows the Apps header when actual installed app rows are present", () => { - setAppSummaries([APP_SUGGESTION.app]); - - renderActions(); - - expect(screen.getByText("Apps")).toBeTruthy(); - expect(screen.getByRole("button", { name: /Review Board/u })).toBeTruthy(); - }); - - it("shows Open browser in the Actions section on the desktop build", () => { - window.bbDesktop = createBbDesktopApi(DESKTOP_INFO); - - renderActions({ onOpenBrowser: vi.fn() }); - - expect(screen.getByRole("button", { name: /Open browser/u })).toBeTruthy(); - expect(screen.getByText("Actions")).toBeTruthy(); - }); - - it("orders action rows as Open browser, Start terminal, then Create App when no apps exist", () => { - window.bbDesktop = createBbDesktopApi(DESKTOP_INFO); - - renderActions({ onOpenBrowser: vi.fn(), onStartTerminal: vi.fn() }); - - expect( - screen.getAllByRole("button").map((button) => button.textContent ?? ""), - ).toEqual(["Open browser", "Start terminal", "Create App..."]); - expect(screen.getByRole("separator")).toBeTruthy(); - expect(screen.getByText("Apps")).toBeTruthy(); + expect(screen.queryByRole("textbox")).toBeNull(); }); - it("orders installed apps between the open actions and Create App, with a divider and Apps title", () => { + it("orders action rows as Open browser, then Start terminal", () => { window.bbDesktop = createBbDesktopApi(DESKTOP_INFO); - setAppSummaries([APP_SUGGESTION.app]); renderActions({ onOpenBrowser: vi.fn(), onStartTerminal: vi.fn() }); - // Open actions, the installed app rows, then Create App last. expect( screen.getAllByRole("button").map((button) => button.textContent ?? ""), - ).toEqual([ - "Open browser", - "Start terminal", - expect.stringContaining("Review Board"), - "Create App...", - ]); - - // Apps get their own divided, titled section after the open actions. - const divider = screen.getByRole("separator"); - const appsTitle = screen.getByText("Apps"); - expect(appsTitle.parentElement?.className).toContain( - CHROME_SECTION_LABEL_CLASS, - ); - expect(divider.className).toContain("mx-2"); - expect(divider.className).toContain("w-auto"); - expect(divider.className).toContain("bg-border-seam"); - const startTerminal = screen.getByRole("button", { - name: /Start terminal/u, - }); - const appRow = screen.getByRole("button", { name: /Review Board/u }); - const orderedAfter = (a: Element, b: Element) => - Boolean(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING); - // Divider follows Start terminal; the Apps title follows the divider; the - // app row follows the title. - expect(orderedAfter(startTerminal, divider)).toBe(true); - expect(orderedAfter(divider, appsTitle)).toBe(true); - expect(orderedAfter(appsTitle, appRow)).toBe(true); - }); - - it("caps installed apps behind show-more while keeping Create App last", () => { - setAppSummaries( - Array.from({ length: 8 }, (_value, index) => makeAppSummary(index + 1)), - ); - - renderActions(); - - const actions = screen.getByTestId("new-tab-actions"); - expect(screen.getByRole("button", { name: /App 1/u })).toBeTruthy(); - expect(screen.getByRole("button", { name: /App 6/u })).toBeTruthy(); - expect(screen.queryByRole("button", { name: /App 7/u })).toBeNull(); - expect(screen.getByRole("button", { name: "Show 2 more" })).toBeTruthy(); - expect( - within(actions).getAllByRole("button").at(-1)?.textContent, - ).toBe("Create App..."); - - fireEvent.click(screen.getByRole("button", { name: "Show 2 more" })); - - expect(screen.getByRole("button", { name: /App 7/u })).toBeTruthy(); - expect(screen.getByRole("button", { name: /App 8/u })).toBeTruthy(); - expect(screen.getByRole("button", { name: "Show less" })).toBeTruthy(); - expect( - within(actions).getAllByRole("button").at(-1)?.textContent, - ).toBe("Create App..."); - }); - - it("keeps Create App last while apps are loading", () => { - appsQueryMockState.isLoading = true; - appsQueryMockState.data = undefined; - - renderActions(); - - const actions = screen.getByTestId("new-tab-actions"); - const createApp = screen.getByRole("button", { name: "Create App..." }); - const status = screen.getByText("Loading apps..."); - - // The loading notice renders above Create App, never after it, so Create - // App stays visually last in the loading state. - expect( - Boolean( - status.compareDocumentPosition(createApp) & - Node.DOCUMENT_POSITION_FOLLOWING, - ), - ).toBe(true); - expect( - Boolean( - createApp.compareDocumentPosition(status) & - Node.DOCUMENT_POSITION_FOLLOWING, - ), - ).toBe(false); - expect(within(actions).getAllByRole("button").at(-1)).toBe(createApp); - }); - - it("keeps Create App last when apps fail to load", () => { - appsQueryMockState.isError = true; - appsQueryMockState.data = undefined; - - renderActions(); - - const actions = screen.getByTestId("new-tab-actions"); - const createApp = screen.getByRole("button", { name: "Create App..." }); - const status = screen.getByText("Couldn't load apps."); - - // The error notice renders above Create App, never after it, so Create App - // stays visually last in the error state. - expect( - Boolean( - status.compareDocumentPosition(createApp) & - Node.DOCUMENT_POSITION_FOLLOWING, - ), - ).toBe(true); - expect( - Boolean( - createApp.compareDocumentPosition(status) & - Node.DOCUMENT_POSITION_FOLLOWING, - ), - ).toBe(false); - expect(within(actions).getAllByRole("button").at(-1)).toBe(createApp); + ).toEqual(["Open browser", "Start terminal"]); + expect(screen.queryByRole("separator")).toBeNull(); }); it("keeps page actions compact with native button semantics and non-ring focus", () => { @@ -467,57 +212,16 @@ describe("NewTabActions", () => { expect(onStartTerminal).toHaveBeenCalledTimes(1); }); - it("starts Create App directly", () => { - const onCreateAppPromptPrefill = vi.fn(); - renderActions({ onCreateAppPromptPrefill }); - - fireEvent.click(screen.getByRole("button", { name: "Create App..." })); - - expect(onCreateAppPromptPrefill).toHaveBeenCalledTimes(1); - expect(promptDraftMockState.setDrafts).toEqual([ - { - text: expect.stringContaining("You are creating a new global bb app."), - mentions: [], - attachments: [], - }, - ]); - }); - - it("leaves a non-empty composer draft unchanged when Create App replacement is canceled", () => { - promptDraftMockState.currentDraft = DRAFT_WITH_ATTACHMENT; - vi.spyOn(window, "confirm").mockReturnValue(false); - const onCreateAppPromptPrefill = vi.fn(); - - renderActions({ onCreateAppPromptPrefill }); - - fireEvent.click(screen.getByRole("button", { name: "Create App..." })); - - expect(promptDraftMockState.setDrafts).toEqual([]); - expect(onCreateAppPromptPrefill).not.toHaveBeenCalled(); - }); }); describe("NewTabFileSearch", () => { - it("includes app rows as search results while a query is active", () => { - setFileSearchSuggestions([APP_SUGGESTION, FILE_SUGGESTION]); - - renderLauncher({ initialQuery: "review" }); - - expect(screen.getByRole("option", { name: /Review Board/u })).toBeTruthy(); - expect(screen.getByRole("option", { name: /app\.ts/u })).toBeTruthy(); - expect(screen.getByText("Apps")).toBeTruthy(); - expect( - screen.queryByRole("button", { name: "Back to new tab menu" }), - ).toBeNull(); - }); - it("wires the search box to a single combobox listbox that holds the active option", () => { setFileSearchSuggestions([FILE_SUGGESTION]); - renderLauncher(); + renderLauncher({ initialQuery: "app" }); const input = screen.getByRole("combobox", { - name: "Search files and apps", + name: "Search files", }); // Exactly one listbox: the combobox controls a single popup spanning the // Files and Recent groups, so its active descendant always resolves within @@ -537,13 +241,13 @@ describe("NewTabFileSearch", () => { ).toBe(true); }); - it("groups Files and Recent as labelled option groups inside the one listbox", () => { + it("groups Files as a labelled option group inside the one listbox", () => { setFileSearchSuggestions([FILE_SUGGESTION]); - renderLauncher(); + renderLauncher({ initialQuery: "app" }); const listbox = screen.getByRole("listbox", { - name: "File and app search results", + name: "File search results", }); const filesGroup = within(listbox).getByRole("group", { name: "Files" }); expect( @@ -565,21 +269,31 @@ describe("NewTabFileSearch", () => { ); setFileSearchSuggestions([REPORT_FILE_SUGGESTION]); - renderLauncher({ currentThreadId }); + renderLauncher({ currentThreadId, initialQuery: "desktop" }); const listbox = screen.getByRole("listbox", { - name: "File and app search results", + name: "File search results", }); const filesGroup = within(listbox).getByRole("group", { name: "Files" }); - const recentGroup = within(listbox).getByRole("group", { name: "Recent" }); const fileRow = within(filesGroup).getByRole("option", { name: /desktop-size\.html/u, }); + + expect(fileRow.querySelector("[data-icon='ChartColumn']")).not.toBeNull(); + + fireEvent.change(screen.getByRole("combobox", { name: "Search files" }), { + target: { value: "" }, + }); + + const recentGroup = within( + screen.getByRole("listbox", { + name: "File search results", + }), + ).getByRole("group", { name: "Recent" }); const recentRow = within(recentGroup).getByRole("option", { name: /desktop-size\.html/u, }); - expect(fileRow.querySelector("[data-icon='ChartColumn']")).not.toBeNull(); expect(recentRow.querySelector("[data-icon='ChartColumn']")).not.toBeNull(); expect(within(recentRow).queryByText("Report")).toBeNull(); expect(recentRow.textContent ?? "").not.toContain(String.fromCharCode(183)); @@ -588,7 +302,11 @@ describe("NewTabFileSearch", () => { it("surfaces workspace files for a projectless thread that has an environment", () => { setFileSearchSuggestions([FILE_SUGGESTION]); - renderLauncher({ projectId: PERSONAL_PROJECT_ID, environmentId: "env_1" }); + renderLauncher({ + projectId: PERSONAL_PROJECT_ID, + environmentId: "env_1", + initialQuery: "app", + }); // The component forwards project and environment ids verbatim; the source // decision (search the environment workspace, not the personal "project") @@ -598,16 +316,20 @@ describe("NewTabFileSearch", () => { expect(screen.getByRole("option", { name: /app\.ts/u })).toBeTruthy(); }); - it("hides workspace files for a projectless thread without an environment", () => { + it("forwards projectless threads without an environment to the suggestion hook", () => { setFileSearchSuggestions([FILE_SUGGESTION]); - renderLauncher({ projectId: PERSONAL_PROJECT_ID, environmentId: null }); + renderLauncher({ + projectId: PERSONAL_PROJECT_ID, + environmentId: null, + initialQuery: "app", + }); - // No project source and no environment ⇒ no workspace to search, so - // workspace suggestions are filtered out of the results. - expect(screen.queryByRole("option", { name: /app\.ts/u })).toBeNull(); + expect(fileSearchMockArgs.at(-1)?.projectId).toBe(PERSONAL_PROJECT_ID); + expect(fileSearchMockArgs.at(-1)?.environmentId).toBeNull(); + expect(screen.getByRole("option", { name: /app\.ts/u })).toBeTruthy(); expect( - screen.getByRole("combobox", { name: "Search files and apps" }), + screen.getByRole("combobox", { name: "Search files" }), ).toHaveProperty("disabled", false); }); }); diff --git a/apps/app/src/components/secondary-panel/NewTabFileSearch.tsx b/apps/app/src/components/secondary-panel/NewTabFileSearch.tsx index 484834f043..f8d6823abd 100644 --- a/apps/app/src/components/secondary-panel/NewTabFileSearch.tsx +++ b/apps/app/src/components/secondary-panel/NewTabFileSearch.tsx @@ -18,17 +18,12 @@ import { import { Icon } from "@/components/ui/icon.js"; import { EmptyStatePanel } from "@/components/ui/empty-state.js"; import { Input } from "@/components/ui/input.js"; -import { Separator } from "@/components/ui/separator.js"; import { TruncateStart } from "@/components/ui/truncate-start.js"; -import { ResolvedAppIcon } from "./AppIcon"; import { useFileSearchSuggestions, - type AppSearchSuggestion, type FilePathSearchSuggestion, type FileSearchSuggestion, } from "@/hooks/useFileSearchSuggestions"; -import { usePromptDraftStorage } from "@/hooks/usePromptDraftStorage"; -import { useApps } from "@/hooks/queries/thread-queries"; import type { FileSearchSelection } from "./useThreadFileTabs"; import { useThreadRecentItems, @@ -41,9 +36,7 @@ import { } from "./rightPanelFileVisuals"; import { cn } from "@/lib/utils"; import { isDesktopBrowserAvailable } from "@/lib/bb-desktop"; -import { isProjectlessProjectId } from "@/lib/app-route-paths"; import { formatRelativeTime } from "@/lib/relative-time"; -import { isPromptDraftEmpty, type PromptDraftState } from "@/lib/prompt-draft"; import { LAUNCHER_ACTION_ROW_BASE_CLASS, LAUNCHER_ROW_BASE_CLASS, @@ -52,30 +45,6 @@ import { LauncherSectionHeader, } from "./launcherRow"; -export const CREATE_APP_PROMPT_TEMPLATE = `You are creating a new global bb app. - -Apps system reference — run \`bb guide app\` for full detail. Layout: -- /apps//manifest.json — { manifestVersion: 1, id: applicationId, name?, icon | logo.svg, entry, capabilities: ["data"?, "message"?] } -- /apps//README.md — scaffold notes and build instructions -- /apps//public/index.html — prebuilt static web root served by bb; use flat relative asset refs -- /apps//data/state.json — empty seed state; app data can also use nested records such as todos/ -- /apps//skills/add-todos/SKILL.md — scaffold skill showing the Todo record shape -- /apps//source/ — editable Vite + React + TypeScript project; run \`pnpm install\` and \`pnpm build\` here after edits - -In the page, use the injected window.bb SDK: window.bb.data.read({ path }), window.bb.data.write({ path, value }), window.bb.data.delete({ path }), window.bb.data.list({ prefix }), window.bb.data.onChange({ prefix, callback }) for live state, and window.bb.message.send({ payload }) to send the thread a prompt. - -Scaffold with \`bb app new --name "Name"\` or \`bb app new --slug my-app\`; new apps open immediately from committed \`public/\`. Edit \`source/\`, rebuild to \`public/\`, and do not rely on a localhost dev server for the installed app. Inside an app-capable runtime, inspect \`bb app current --json\` and write directly to \`BB_APP_ROOT\` / \`BB_APP_DATA_PATH\`. The application id is the lowercase slug folder name; display names are optional labels, not identifiers. - -What I want: - -`; - -const CREATE_APP_PROMPT_DRAFT = { - text: CREATE_APP_PROMPT_TEMPLATE, - mentions: [], - attachments: [], -} satisfies PromptDraftState; - export interface NewTabFileSearchProps { projectId: string | undefined; environmentId: string | null; @@ -86,30 +55,16 @@ export interface NewTabFileSearchProps { onSelect: (selection: FileSearchSelection) => void; } -export type CreateAppPromptPrefillHandler = () => void; export type OpenBrowserHandler = () => void; export type SearchActiveChangeHandler = (isSearchActive: boolean) => void; export type StartTerminalHandler = () => void; export interface NewTabActionsProps { - projectId: string | undefined; - currentThreadId: string; - onSelect: (selection: FileSearchSelection) => void; - onCreateAppPromptPrefill?: CreateAppPromptPrefillHandler; /** Desktop-only: open a new in-panel browser tab. Absent ⇒ no Browser entry. */ onOpenBrowser?: OpenBrowserHandler; onStartTerminal?: StartTerminalHandler; } -interface AppResultRowProps { - id: string; - suggestion: AppSearchSuggestion; - isActive: boolean; - variant?: LauncherTileVariant; - onActivate: () => void; - onSelect: (suggestion: AppSearchSuggestion) => void; -} - interface FileResultRowProps { id: string; suggestion: FilePathSearchSuggestion; @@ -156,18 +111,11 @@ interface FileSearchSection { type LauncherKeyDownHandler = (event: KeyboardEvent) => void; type FileSearchSource = FileSearchSuggestion["source"]; -type FileSearchSectionKind = "actions" | "apps" | "files" | "recent"; +type FileSearchSectionKind = "actions" | "files" | "recent"; type LauncherTileVariant = "result" | "action"; -interface GetAvailableFileSearchSourcesArgs { - projectId: string | undefined; - environmentId: string | null; - currentThreadId: string; -} - interface GroupFileSearchSectionsArgs { suggestions: readonly FileSearchSuggestion[]; - availableSources: readonly FileSearchSource[]; recentEntries: readonly FileSearchSectionEntry[]; } @@ -181,13 +129,6 @@ interface LauncherTileProps { children: ReactNode; } -interface CreateAppTileProps { - id: string; - isActive: boolean; - onActivate: () => void; - onSelect: () => void; -} - interface OpenBrowserTileProps { id: string; isActive: boolean; @@ -211,64 +152,29 @@ interface ShowMoreToggleProps { const FILE_SEARCH_LIMIT = 20; const FILE_SEARCH_SECTION_ORDER: readonly FileSearchSectionKind[] = [ "files", - "apps", "recent", "actions", ]; const FILE_SEARCH_SECTION_LABELS = { actions: "Actions", - apps: "Apps", files: "Files", recent: "Recent", } satisfies Record; const FILE_SEARCH_SOURCE_LABELS = { - app: "App", workspace: "Workspace", "thread-storage": "Thread storage", } satisfies Record; -const CREATE_APP_ENTRY_ID = "file-search-result-create-app"; const OPEN_BROWSER_ENTRY_ID = "file-search-result-open-browser"; const START_TERMINAL_ENTRY_ID = "file-search-result-start-terminal"; -const LAUNCHER_TILE_ICON_CLASS_DASHED = `flex shrink-0 items-center justify-center text-muted-foreground group-hover:text-foreground ${COARSE_POINTER_ICON_SIZE_CLASS}`; -const NEW_TAB_ACTIONS_SEPARATOR_CLASS = "mx-2 my-2 w-auto bg-border-seam"; - const RECENT_ENTRY_ID_PREFIX = "file-search-result-recent"; -const NEW_TAB_APP_ROWS_VISIBLE_LIMIT = 6; - -function getAvailableFileSearchSources({ - projectId, - environmentId, - currentThreadId, -}: GetAvailableFileSearchSourcesArgs): readonly FileSearchSource[] { - const sources: FileSearchSource[] = []; - if (currentThreadId.length > 0) { - sources.push("app"); - } - // The workspace is searchable via an existing thread's environment, or via a - // standard project's default source before any environment exists. Projectless - // (personal) threads have no project source, so without an environment there - // is no workspace to search. Mirrors the source selection in usePathSuggestions. - if ( - Boolean(environmentId) || - (projectId && !isProjectlessProjectId(projectId)) - ) { - sources.push("workspace"); - } - if (currentThreadId.length > 0) { - sources.push("thread-storage"); - } - return sources; -} function getFileSearchResultId(suggestion: FileSearchSuggestion): string { - const idSegment = - suggestion.entryKind === "app" ? suggestion.applicationId : suggestion.path; return `file-search-result-${suggestion.source}-${encodeURIComponent( - idSegment, + suggestion.path, )}`; } @@ -282,24 +188,19 @@ function getFileSearchEntryId(entry: FileSearchSectionEntry): string { } function getFileSearchResultTitle(suggestion: FileSearchSuggestion): string { - if (suggestion.entryKind === "app") { - return `${FILE_SEARCH_SOURCE_LABELS.app}: ${suggestion.name}`; - } return `${FILE_SEARCH_SOURCE_LABELS[suggestion.source]}: ${suggestion.path}`; } function getFileSearchSectionKind( suggestion: FileSearchSuggestion, ): FileSearchSectionKind { - return suggestion.entryKind === "app" ? "apps" : "files"; + return "files"; } function groupFileSearchSections({ - availableSources, recentEntries, suggestions, }: GroupFileSearchSectionsArgs): FileSearchSection[] { - const allowedSources = new Set(availableSources); const sectionsByKind = new Map(); const ensureSection = ( @@ -319,9 +220,6 @@ function groupFileSearchSections({ }; for (const suggestion of suggestions) { - if (!allowedSources.has(suggestion.source)) { - continue; - } ensureSection(getFileSearchSectionKind(suggestion)).items.push({ entry: { kind: "suggestion", suggestion }, index: 0, @@ -414,70 +312,6 @@ function LauncherTile({ ); } -function AppResultRow({ - id, - suggestion, - isActive, - variant = "action", - onActivate, - onSelect, -}: AppResultRowProps) { - const handleSelect = useCallback(() => { - onSelect(suggestion); - }, [onSelect, suggestion]); - return ( - - - - - - {suggestion.name} - - - ); -} - -function CreateAppTile({ - id, - isActive, - onActivate, - onSelect, -}: CreateAppTileProps) { - return ( - - - - - - Create App... - - - ); -} - function OpenBrowserTile({ id, isActive, @@ -687,7 +521,7 @@ export function NewTabFileSearch({ ); const trimmedQuery = query.trim(); const hasQuery = trimmedQuery.length > 0; - const { suggestions, isLoading, appsError, fileSearchError, isDebouncing } = + const { suggestions, isLoading, fileSearchError, isDebouncing, isUnavailable } = useFileSearchSuggestions({ projectId, query, @@ -695,27 +529,8 @@ export function NewTabFileSearch({ environmentId, currentThreadId, }); - const availableSources = useMemo( - () => - getAvailableFileSearchSources({ - projectId, - environmentId, - currentThreadId, - }), - [currentThreadId, environmentId, projectId], - ); - const fileSearchSources = useMemo( - () => availableSources.filter((source) => source !== "app"), - [availableSources], - ); const searchSuggestions = useMemo( - () => - hasQuery - ? suggestions - : suggestions.filter( - (suggestion): suggestion is FilePathSearchSuggestion => - suggestion.entryKind === "file", - ), + () => (hasQuery ? suggestions : []), [hasQuery, suggestions], ); // Collapsed to the visible cap by default. Recents are file/artifact entries, @@ -738,17 +553,10 @@ export function NewTabFileSearch({ const sections = useMemo( () => groupFileSearchSections({ - availableSources: hasQuery ? availableSources : fileSearchSources, recentEntries, suggestions: searchSuggestions, }), - [ - availableSources, - fileSearchSources, - hasQuery, - recentEntries, - searchSuggestions, - ], + [recentEntries, searchSuggestions], ); const navigableEntries = useMemo( () => @@ -806,13 +614,9 @@ export function NewTabFileSearch({ const handleSuggestionSelect = useCallback( (suggestion: FileSearchSuggestion) => { - if (suggestion.entryKind === "file") { - handleFileSelect(suggestion); - return; - } - onSelect({ source: "app", applicationId: suggestion.applicationId }); + handleFileSelect(suggestion); }, - [handleFileSelect, onSelect], + [handleFileSelect], ); const handleLauncherKeyDown = useCallback( @@ -857,7 +661,7 @@ export function NewTabFileSearch({ const activeEntryId = activeEntry ? getFileSearchEntryId(activeEntry) : undefined; - const isSearchDisabled = availableSources.length === 0; + const isSearchDisabled = isUnavailable; // The results listbox renders only when there is a searchable source and at // least one option. Gate the combobox relationship on that so // `aria-controls`/`aria-activedescendant` never point at an absent element. @@ -880,16 +684,16 @@ export function NewTabFileSearch({ onKeyDown={handleLauncherKeyDown} disabled={isSearchDisabled} // Combobox with a list autocomplete popup: one listbox holds the - // navigable Files/Apps/Recent options, and the highlighted row is the + // navigable Files/Recent options, and the highlighted row is the // combobox's active descendant within that controlled listbox. role="combobox" - aria-label="Search files and apps" + aria-label="Search files" aria-autocomplete="list" aria-expanded={hasListbox} aria-controls={hasListbox ? listboxId : undefined} aria-activedescendant={hasListbox ? activeEntryId : undefined} placeholder={ - isSearchDisabled ? "No searchable source" : "Search files and apps" + isSearchDisabled ? "No searchable source" : "Search files" } className={cn( "h-8 pl-8 pr-8 focus-visible:ring-0 max-md:pointer-coarse:h-10", @@ -906,7 +710,7 @@ export function NewTabFileSearch({ /> ) : null} - {availableSources.length === 0 ? ( + {isUnavailable ? ( 0 ? currentThreadId : null, - }); - const canSearchApps = currentThreadId.length > 0; - const apps = useApps({ enabled: canSearchApps }); - const appSuggestions = useMemo( - () => - (apps.data ?? []).map((app) => ({ - source: "app", - entryKind: "app", - app, - applicationId: app.applicationId, - name: app.name, - score: 0, - })), - [apps.data], - ); - const visibleAppSuggestions = useMemo( - () => - isAppsExpanded - ? appSuggestions - : appSuggestions.slice(0, NEW_TAB_APP_ROWS_VISIBLE_LIMIT), - [appSuggestions, isAppsExpanded], - ); - const canPrefillCreateAppPrompt = - promptDraft.storageKey !== null && currentThreadId.length > 0; const showOpenBrowserEntry = onOpenBrowser !== undefined && isDesktopBrowserAvailable(); const showStartTerminalEntry = onStartTerminal !== undefined; - const showCreateAppEntry = canPrefillCreateAppPrompt; - - const handleAppSelect = useCallback( - (suggestion: AppSearchSuggestion) => { - onSelect({ source: "app", applicationId: suggestion.applicationId }); - }, - [onSelect], - ); const handleOpenBrowser = useCallback(() => { onOpenBrowser?.(); @@ -999,135 +763,38 @@ export function NewTabActions({ onStartTerminal?.(); }, [onStartTerminal]); - const handleToggleAppsExpanded = useCallback(() => { - setIsAppsExpanded((current) => !current); - }, []); - - const handleCreateAppPromptPrefill = useCallback(() => { - if (!canPrefillCreateAppPrompt) { - return; - } - - const currentDraft = promptDraft.getCurrent(); - if ( - !isPromptDraftEmpty(currentDraft) && - !window.confirm( - "Replace the current composer draft with a Create App prompt?", - ) - ) { - return; - } - - promptDraft.setDraft(CREATE_APP_PROMPT_DRAFT); - onCreateAppPromptPrefill?.(); - }, [canPrefillCreateAppPrompt, onCreateAppPromptPrefill, promptDraft]); - - const hasInstalledApps = appSuggestions.length > 0; - const showAppsToggle = - appSuggestions.length > NEW_TAB_APP_ROWS_VISIBLE_LIMIT; - const showAppsMoreCount = Math.max( - 0, - appSuggestions.length - NEW_TAB_APP_ROWS_VISIBLE_LIMIT, - ); const hasOpenActions = showOpenBrowserEntry || showStartTerminalEntry; - const hasAppActions = - hasInstalledApps || apps.isLoading || apps.isError || showCreateAppEntry; - if (!hasOpenActions && !hasAppActions) { + if (!hasOpenActions) { return null; } return (
- {hasOpenActions ? ( -
- -
- {showOpenBrowserEntry ? ( - undefined} - onSelect={handleOpenBrowser} - /> - ) : null} - {showStartTerminalEntry ? ( - undefined} - onSelect={handleStartTerminal} - /> - ) : null} -
-
- ) : null} - - {hasOpenActions && hasAppActions ? ( - + - ) : null} - - {hasAppActions ? ( -
- -
- {visibleAppSuggestions.map((suggestion) => ( - undefined} - onSelect={handleAppSelect} - /> - ))} - {canSearchApps && apps.isLoading && appSuggestions.length === 0 ? ( -

- Loading apps... -

- ) : null} - {canSearchApps && apps.isError ? ( -

- Couldn't load apps. -

- ) : null} - {showAppsToggle ? ( - - ) : null} - {showCreateAppEntry ? ( - undefined} - onSelect={handleCreateAppPromptPrefill} - /> - ) : null} -
-
- ) : null} +
+ {showOpenBrowserEntry ? ( + undefined} + onSelect={handleOpenBrowser} + /> + ) : null} + {showStartTerminalEntry ? ( + undefined} + onSelect={handleStartTerminal} + /> + ) : null} +
+
); } @@ -1146,7 +813,7 @@ interface NewTabResultsProps { hasQuery: boolean; searchError: boolean; isLoading: boolean; - /** Id of the single combobox listbox that wraps the Files/Apps/Recent option groups. */ + /** Id of the single combobox listbox that wraps the Files/Recent option groups. */ listboxId: string; nowMs: number; onActivateIndex: (index: number) => void; @@ -1169,14 +836,12 @@ function NewTabResults({ recent, sections, }: NewTabResultsProps) { - const appsSection = sections.find((section) => section.kind === "apps"); const filesSection = sections.find((section) => section.kind === "files"); const recentSection = sections.find((section) => section.kind === "recent"); - const showAppsSection = appsSection !== undefined; const showFilesSection = filesSection !== undefined; const showRecentSection = !hasQuery && (recentSection !== undefined || recent.emptyHintVisible); - const hasSearchResults = showFilesSection || showAppsSection; + const hasSearchResults = showFilesSection; const showLoading = isLoading && !hasSearchResults; const showError = searchError && !hasSearchResults && !showLoading; const showNoSearchResults = @@ -1191,7 +856,7 @@ function NewTabResults({ // message, the empty-recent card, and the show-more toggle are not options // and stay outside the listbox. const showListbox = - showFilesSection || showAppsSection || recentSection !== undefined; + showFilesSection || recentSection !== undefined; if (showEmptyMessage) { return ( @@ -1200,7 +865,7 @@ function NewTabResults({ message={ hasQuery ? "No results match your search." - : "Type to search files and apps." + : "Type to search files." } /> ); @@ -1220,7 +885,7 @@ function NewTabResults({ showError ? "Search failed." : showLoading - ? "Searching files and apps..." + ? "Searching files..." : "No results match your search." } /> @@ -1230,7 +895,7 @@ function NewTabResults({
{showFilesSection && filesSection ? (
@@ -1262,42 +927,6 @@ function NewTabResults({
) : null} - {showAppsSection && appsSection ? ( -
- -
- {appsSection.items.map(({ entry, index }) => { - if ( - entry.kind !== "suggestion" || - entry.suggestion.entryKind !== "app" - ) { - return null; - } - const suggestion = entry.suggestion; - return ( - onActivateIndex(index)} - onSelect={onSuggestionSelect} - /> - ); - })} -
-
- ) : null} - {recentSection ? (
{ ...actual, searchProjectPaths: vi.fn(), searchEnvironmentPaths: vi.fn(), - listApps: vi.fn(), listThreadStoragePaths: vi.fn(), }; }); @@ -76,15 +74,6 @@ function makePathResponse( const MINUTE_MS = 60 * 1000; const HOUR_MS = 60 * MINUTE_MS; -const REVIEW_BOARD_APP = { - applicationId: "review-board", - name: "Review Board", - entry: { path: "index.html", kind: "html" }, - capabilities: ["data", "message"], - icon: { kind: "builtin", name: "ListTodo" }, - source: null, -} satisfies AppSummary; - function seedRecentItems(threadId: string, items: ThreadRecentItem[]): void { window.localStorage.setItem( getThreadRecentItemsStorageKey({ threadId }), @@ -93,7 +82,6 @@ function seedRecentItems(threadId: string, items: ThreadRecentItem[]): void { } function mockEmptySearchSources(): void { - vi.mocked(api.listApps).mockResolvedValue([]); vi.mocked(api.searchProjectPaths).mockResolvedValue(makePathResponse([])); vi.mocked(api.searchEnvironmentPaths).mockResolvedValue(makePathResponse([])); vi.mocked(api.listThreadStoragePaths).mockResolvedValue({ @@ -130,7 +118,6 @@ afterEach(() => { describe("NewTabPage", () => { it("renders file search and selects a workspace result", async () => { - vi.mocked(api.listApps).mockResolvedValue([]); vi.mocked(api.searchEnvironmentPaths).mockResolvedValue( makePathResponse([ { @@ -142,13 +129,11 @@ describe("NewTabPage", () => { ); const { onSelect } = renderNewTabPage({ projectId: "proj-1" }); - expect( - screen.queryByRole("textbox", { name: "Search apps and files" }), - ).toBeNull(); + expect(screen.queryByRole("textbox")).toBeNull(); expect(screen.queryByRole("option", { name: /Open file/u })).toBeNull(); expect(screen.queryByRole("option", { name: /Open browser/u })).toBeNull(); const input = screen.getByRole("combobox", { - name: "Search files and apps", + name: "Search files", }); expect(document.activeElement).toBe(input); fireEvent.change(input, { target: { value: "app" } }); @@ -164,21 +149,7 @@ describe("NewTabPage", () => { }); }); - it("structures the create-app prompt with no placeholders and ends ready for the user", () => { - expect(CREATE_APP_PROMPT_TEMPLATE).not.toMatch(/\[NAME\]/u); - expect(CREATE_APP_PROMPT_TEMPLATE).not.toMatch( - /\[DESCRIBE WHAT IT SHOULD DO\]/u, - ); - expect(CREATE_APP_PROMPT_TEMPLATE).toContain("bb guide app"); - expect(CREATE_APP_PROMPT_TEMPLATE).toContain("window.bb.data"); - expect(CREATE_APP_PROMPT_TEMPLATE).toContain("window.bb.message.send"); - expect(CREATE_APP_PROMPT_TEMPLATE).toContain("Vite + React + TypeScript"); - expect(CREATE_APP_PROMPT_TEMPLATE).toContain("pnpm build"); - expect(CREATE_APP_PROMPT_TEMPLATE.endsWith("What I want:\n\n")).toBe(true); - }); - it("selects a thread-storage result with the keyboard", async () => { - vi.mocked(api.listApps).mockResolvedValue([]); vi.mocked(api.searchEnvironmentPaths).mockResolvedValue( makePathResponse([ { @@ -204,7 +175,7 @@ describe("NewTabPage", () => { }); const input = screen.getByRole("combobox", { - name: "Search files and apps", + name: "Search files", }); fireEvent.change(input, { target: { value: "status" } }); await screen.findByText("Files"); @@ -217,63 +188,6 @@ describe("NewTabPage", () => { }); }); - it("searches apps with files and hides default sections while searching", async () => { - const threadId = "thr-search-apps"; - vi.mocked(api.listApps).mockResolvedValue([REVIEW_BOARD_APP]); - vi.mocked(api.searchEnvironmentPaths).mockResolvedValue( - makePathResponse([ - { - kind: "file", - path: "src/review.ts", - score: 80, - }, - ]), - ); - vi.mocked(api.listThreadStoragePaths).mockResolvedValue({ - ...makePathResponse([]), - storageRootPath: "/tmp/thread-storage", - }); - seedRecentItems(threadId, [ - { - source: "thread-storage", - path: "plans/recent-review.md", - openedAt: Date.now() - MINUTE_MS, - }, - ]); - const { onSelect } = renderNewTabPage({ - projectId: "proj-1", - currentThreadId: threadId, - }); - - expect( - await screen.findByRole("button", { name: /Review Board/u }), - ).toBeTruthy(); - fireEvent.change( - screen.getByRole("combobox", { name: "Search files and apps" }), - { - target: { value: "review" }, - }, - ); - - const listbox = await screen.findByRole("listbox", { - name: "File and app search results", - }); - expect( - await within(listbox).findByRole("group", { name: "Files" }), - ).toBeTruthy(); - const appsGroup = within(listbox).getByRole("group", { name: "Apps" }); - fireEvent.click( - within(appsGroup).getByRole("option", { name: /Review Board/u }), - ); - - expect(screen.queryByRole("group", { name: "Recent" })).toBeNull(); - expect(screen.queryByRole("button", { name: "Create App..." })).toBeNull(); - expect(onSelect).toHaveBeenCalledWith({ - source: "app", - applicationId: "review-board", - }); - }); - it("ends file-search loading in a projectless thread with no workspace", async () => { mockEmptySearchSources(); renderNewTabPage({ @@ -283,7 +197,7 @@ describe("NewTabPage", () => { }); fireEvent.change( - screen.getByRole("combobox", { name: "Search files and apps" }), + screen.getByRole("combobox", { name: "Search files" }), { target: { value: "missing" }, }, @@ -293,7 +207,7 @@ describe("NewTabPage", () => { expect(api.listThreadStoragePaths).toHaveBeenCalled(); }); await waitFor(() => { - expect(screen.queryByText("Searching files and apps...")).toBeNull(); + expect(screen.queryByText("Searching files...")).toBeNull(); }); // No project source and no environment ⇒ workspace is never queried. @@ -311,14 +225,13 @@ describe("NewTabPage", () => { ).toBeTruthy(); expect(api.searchProjectPaths).not.toHaveBeenCalled(); expect(api.searchEnvironmentPaths).not.toHaveBeenCalled(); - expect(api.listApps).not.toHaveBeenCalled(); expect(api.listThreadStoragePaths).not.toHaveBeenCalled(); // With no searchable source the combobox is disabled and advertises no // popup, so it never dangles aria-controls/activedescendant at an absent // listbox. const input = screen.getByRole("combobox", { - name: "Search files and apps", + name: "Search files", }); expect(input).toHaveProperty("disabled", true); expect(input.getAttribute("aria-expanded")).toBe("false"); @@ -457,7 +370,7 @@ describe("NewTabPage recent section", () => { }); fireEvent.change( - screen.getByRole("combobox", { name: "Search files and apps" }), + screen.getByRole("combobox", { name: "Search files" }), { target: { value: "does-not-exist" }, }, @@ -484,7 +397,7 @@ describe("NewTabPage recent section", () => { }); const input = screen.getByRole("combobox", { - name: "Search files and apps", + name: "Search files", }); const recentOption = await screen.findByRole("option", { name: /swap-model\.md/u, diff --git a/apps/app/src/components/secondary-panel/NewTabPage.tsx b/apps/app/src/components/secondary-panel/NewTabPage.tsx index 0fab389f90..4cb7a53a0e 100644 --- a/apps/app/src/components/secondary-panel/NewTabPage.tsx +++ b/apps/app/src/components/secondary-panel/NewTabPage.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import { NewTabActions, NewTabFileSearch, - type CreateAppPromptPrefillHandler, type NewTabFileSearchProps, type OpenBrowserHandler, type StartTerminalHandler, @@ -14,7 +13,6 @@ type NewTabPageFileSearchProps = Omit< >; export interface NewTabPageProps extends NewTabPageFileSearchProps { - onCreateAppPromptPrefill?: CreateAppPromptPrefillHandler; onOpenBrowser?: OpenBrowserHandler; onStartTerminal?: StartTerminalHandler; } @@ -29,7 +27,6 @@ export function NewTabPage({ environmentId, focusRequest, initialQuery, - onCreateAppPromptPrefill, onOpenBrowser, onSelect, onStartTerminal, @@ -52,10 +49,6 @@ export function NewTabPage({ /> {isSearchActive ? null : ( diff --git a/apps/app/src/components/secondary-panel/ThreadMetadataContent.tsx b/apps/app/src/components/secondary-panel/ThreadMetadataContent.tsx index 4b67b98fff..831ea816ce 100644 --- a/apps/app/src/components/secondary-panel/ThreadMetadataContent.tsx +++ b/apps/app/src/components/secondary-panel/ThreadMetadataContent.tsx @@ -59,7 +59,7 @@ import { import { getGitStatusDisplay } from "@/components/workspace/workspace-status"; import { useUnarchiveThread } from "../../hooks/mutations/thread-state-mutations"; import { buildParentSelectorOptions } from "@/views/thread-detail/threadParentSelectorOptions"; -import { getThreadRoutePath } from "@/lib/app-route-paths"; +import { getThreadRoutePath } from "@/lib/route-paths"; // --------------------------------------------------------------------------- // Each row of the Info tab is a function component that owns its own raw diff --git a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.test.tsx b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.test.tsx index dfd6ff1714..09443ae1f1 100644 --- a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.test.tsx +++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.test.tsx @@ -178,7 +178,7 @@ afterEach(() => { describe("ThreadSecondaryPanel", () => { it.each([ { - name: "app iframe", + name: "iframe", activeTab: buildActiveFileTab({ id: "app:review-board", filename: "Review Board", @@ -388,13 +388,13 @@ describe("ThreadSecondaryPanel", () => { ])( "keeps iframe previews interactable after secondary panel resize ends via $name", async ({ finishDrag }) => { - const activeAppTab: SecondaryPanelFileTab = { - id: "app:review-board", - filename: "Review Board", + const iframePreviewTab: SecondaryPanelFileTab = { + id: "browser:preview", + filename: "Preview", isActive: true, isPinned: true, leadingVisual: ( - + ), statusLabel: null, onSelect: noop, @@ -402,8 +402,8 @@ describe("ThreadSecondaryPanel", () => { }; renderPanel({ - fileTabs: [activeAppTab], - fileTabContent: