diff --git a/apps/app/src/components/dialogs/EnvironmentRenameDialog.stories.tsx b/apps/app/src/components/dialogs/EnvironmentRenameDialog.stories.tsx index 8b97c87fe0..2b021239be 100644 --- a/apps/app/src/components/dialogs/EnvironmentRenameDialog.stories.tsx +++ b/apps/app/src/components/dialogs/EnvironmentRenameDialog.stories.tsx @@ -26,11 +26,37 @@ const customNameTarget: EnvironmentRenameDialogTarget = { canClearName: true, }; +export function BranchContext() { + const inputRef = useRef(null); + return ( + + + + + + + + ); +} + export function Overview() { const inputRef = useRef(null); return ( - + diff --git a/apps/app/src/components/dialogs/EnvironmentRenameDialog.tsx b/apps/app/src/components/dialogs/EnvironmentRenameDialog.tsx index 38f3d1446a..121169b76d 100644 --- a/apps/app/src/components/dialogs/EnvironmentRenameDialog.tsx +++ b/apps/app/src/components/dialogs/EnvironmentRenameDialog.tsx @@ -5,7 +5,7 @@ const ENVIRONMENT_NAME_MAX_LENGTH = 80; const ENVIRONMENT_NAME_LENGTH_RULE = { limit: ENVIRONMENT_NAME_MAX_LENGTH, - message: `Environment name must be ${ENVIRONMENT_NAME_MAX_LENGTH} characters or fewer.`, + message: `Worktree name must be ${ENVIRONMENT_NAME_MAX_LENGTH} characters or fewer.`, }; export interface EnvironmentRenameDialogTarget { @@ -65,17 +65,24 @@ export function EnvironmentRenameDialogContent({ }: EnvironmentRenameDialogContentProps) { return ( + Branch: {target.branchName} +

+ ) : undefined + } maxLength={ENVIRONMENT_NAME_LENGTH_RULE} autoCapitalize="sentences" clearAction={ target.canClearName ? { - label: "Use branch name", + label: "Clear custom name", onClear: () => onRename(target.id, null), } : undefined diff --git a/apps/app/src/components/dialogs/RenameDialog.tsx b/apps/app/src/components/dialogs/RenameDialog.tsx index 36a7616112..31dff6372a 100644 --- a/apps/app/src/components/dialogs/RenameDialog.tsx +++ b/apps/app/src/components/dialogs/RenameDialog.tsx @@ -51,6 +51,7 @@ interface RenameDialogContentProps { pending: boolean; errorMessage?: string | null; placeholder?: string; + inputDetails?: ReactNode; maxLength?: { limit: number; message: string }; autoCapitalize: "words" | "sentences"; compact?: boolean; @@ -65,6 +66,7 @@ export function RenameDialogContent({ pending, errorMessage, placeholder, + inputDetails, maxLength, autoCapitalize, compact = false, @@ -119,6 +121,7 @@ export function RenameDialogContent({ clearMessage(); }} /> + {inputDetails} {displayedErrorMessage ? (

{displayedErrorMessage}

) : null} diff --git a/apps/app/src/components/plugin/PluginThreadChat.test.tsx b/apps/app/src/components/plugin/PluginThreadChat.test.tsx index 26e000ef2c..945b0c5313 100644 --- a/apps/app/src/components/plugin/PluginThreadChat.test.tsx +++ b/apps/app/src/components/plugin/PluginThreadChat.test.tsx @@ -30,6 +30,7 @@ vi.mock("@/lib/sdk", () => ({ })); vi.mock("@/hooks/useRealtimeSubscription", () => ({ + useHostListRealtimeSubscription: vi.fn(), useThreadDetailRealtimeSubscription: vi.fn(), useThreadListRealtimeSubscription: vi.fn(), useEnvironmentDetailRealtimeSubscription: vi.fn(), diff --git a/apps/app/src/components/plugin/PluginThreadChat.tsx b/apps/app/src/components/plugin/PluginThreadChat.tsx index b1ca9195ad..39c8db5202 100644 --- a/apps/app/src/components/plugin/PluginThreadChat.tsx +++ b/apps/app/src/components/plugin/PluginThreadChat.tsx @@ -22,10 +22,13 @@ import { useThreadTimelineNavigation } from "@/components/thread/timeline/Thread import { PluginContext } from "@/components/plugin/plugin-context"; import { ThreadProviderContext } from "@/components/thread/thread-provider-context"; import { useEnvironment } from "@/hooks/queries/environment-queries"; +import { useHosts } from "@/hooks/queries/host-queries"; import { useSystemProviderInfo } from "@/hooks/queries/system-queries"; import { useThread } from "@/hooks/queries/thread-queries"; import { useHostDaemon } from "@/hooks/useHostDaemon"; -import { getEnvironmentWorkspaceLabelIconName } from "@/lib/environment-workspace-display"; +import { + getEnvironmentWorkspaceSummaryDisplay, +} from "@/lib/environment-workspace-display"; import { formatWorkspaceCheckoutDisplay } from "@/lib/workspace-checkout-display"; import { BbHttpError } from "@/lib/sdk"; import { @@ -107,6 +110,11 @@ function PluginThreadChatBody({ const { isLocalDaemonHost } = useHostDaemon(); const environmentQuery = useEnvironment(thread?.environmentId ?? null); const environment = environmentQuery.data ?? null; + const hostsQuery = useHosts({ enabled: environment !== null }); + const environmentHostName = environment + ? (hostsQuery.data?.find((host) => host.id === environment.hostId)?.name ?? + null) + : null; const timelineNavigation = useThreadTimelineNavigation(); const canUseHostFileNavigation = thread !== undefined && @@ -171,25 +179,25 @@ function PluginThreadChatBody({ const environmentSummary = useMemo(() => { if (environment === null) { - return ( - - ); + return null; } const host: EnvironmentDisplayHostContext = { locality: isLocalDaemonHost(environment.hostId) ? "local" : "remote", identity: null, }; const display = formatEnvironmentDisplay({ environment, host }); + const summaryDisplay = getEnvironmentWorkspaceSummaryDisplay({ + display, + environmentName: environment.name, + locality: host.locality, + hostName: environmentHostName ?? undefined, + }); return ( ); - }, [environment, isLocalDaemonHost]); + }, [environment, environmentHostName, isLocalDaemonHost]); const isThreadMissing = threadQuery.error instanceof BbHttpError && diff --git a/apps/app/src/components/promptbox/FollowUpPromptBox.stories.tsx b/apps/app/src/components/promptbox/FollowUpPromptBox.stories.tsx index 31f6cd8ae9..9b575e44f7 100644 --- a/apps/app/src/components/promptbox/FollowUpPromptBox.stories.tsx +++ b/apps/app/src/components/promptbox/FollowUpPromptBox.stories.tsx @@ -1,4 +1,10 @@ -import { useCallback, useMemo, useState, type ReactNode } from "react"; +import { + useCallback, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; import type { Environment, PermissionMode, @@ -24,7 +30,9 @@ import { getFollowUpPromptPlaceholder, getCompactFollowUpPromptPlaceholder, } from "@/components/promptbox/follow-up-placeholder"; -import { getEnvironmentWorkspaceLabelIconName } from "@/lib/environment-workspace-display"; +import { + getEnvironmentWorkspaceSummaryDisplay, +} from "@/lib/environment-workspace-display"; import { INERT_TYPEAHEAD_COMMAND_CONFIG, type AttachmentsConfig, @@ -42,6 +50,7 @@ import { type QueuedMessageInlineEditor, } from "@/components/promptbox/banner/QueuedMessagesList"; import { ThreadEnvironmentSummary } from "@/components/promptbox/ThreadEnvironmentSummary"; +import { EnvironmentRenameDialogContent } from "@/components/dialogs/EnvironmentRenameDialog"; import { formatWorkspaceCheckoutDisplay, type WorkspaceCheckoutDisplay, @@ -49,6 +58,7 @@ import { import type { PickerOption } from "@/components/pickers/OptionPicker"; import { selectWorkspaceChangedFilesSection } from "@/components/workspace/workspace-change-summary"; import { StoryCard, StoryRow } from "../../../.ladle/story-card"; +import { DialogStage } from "../../../.ladle/story-dialog-stage"; import { makeEnvironment, makeExecutionControlsProps, @@ -168,6 +178,8 @@ const readOnlyPermission: ExecutionPermissionConfig = { interface EnvironmentSummaryArgs { environment: Environment; host: EnvironmentDisplayHostContext; + projectName?: string; + machineName?: string; branchName?: string; environmentCheckout?: WorkspaceCheckoutDisplay; onCreateNewThreadInWorktree?: () => void; @@ -176,6 +188,8 @@ interface EnvironmentSummaryArgs { function makeEnvironmentSummary({ environment, host, + projectName, + machineName, branchName, environmentCheckout, onCreateNewThreadInWorktree, @@ -184,6 +198,13 @@ function makeEnvironmentSummary({ environment, host, }); + const summaryDisplay = getEnvironmentWorkspaceSummaryDisplay({ + display, + environmentName: environment.name, + locality: host.locality, + hostName: machineName, + machinePrefix: machineName ? `${machineName} · ` : "", + }); const checkoutDisplay = environmentCheckout ?? (branchName @@ -197,11 +218,11 @@ function makeEnvironmentSummary({ : undefined); return ( @@ -226,6 +247,20 @@ const localEnvironmentSummary: ReactNode = makeEnvironmentSummary({ status: "ready", }), host: localEnvironmentDisplayHost, + machineName: "Bersabel's MacBook Pro", + branchName: STORY_BRANCH_NAME, +}); + +const longHostEnvironmentSummary: ReactNode = makeEnvironmentSummary({ + environment: makeEnvironment({ + managed: false, + isWorktree: false, + workspaceProvisionType: "unmanaged", + status: "ready", + }), + host: localEnvironmentDisplayHost, + projectName: "bb UI QA", + machineName: "Bersabel's MacBook Pro", branchName: STORY_BRANCH_NAME, }); @@ -237,6 +272,7 @@ const remoteEnvironmentSummary: ReactNode = makeEnvironmentSummary({ status: "ready", }), host: remoteEnvironmentDisplayHost, + machineName: "Build Mac mini", branchName: STORY_BRANCH_NAME, }); @@ -247,6 +283,31 @@ const worktreeEnvironmentSummary: ReactNode = makeEnvironmentSummary({ status: "ready", }), host: localEnvironmentDisplayHost, + machineName: "Bersabel's MacBook Pro", + branchName: STORY_BRANCH_NAME, + onCreateNewThreadInWorktree: noop, +}); + +const remoteWorktreeEnvironmentSummary: ReactNode = makeEnvironmentSummary({ + environment: makeEnvironment({ + isWorktree: true, + workspaceProvisionType: "managed-worktree", + status: "ready", + }), + host: remoteEnvironmentDisplayHost, + machineName: "Build Mac mini", + branchName: STORY_BRANCH_NAME, + onCreateNewThreadInWorktree: noop, +}); + +const namedWorktreeEnvironmentSummary: ReactNode = makeEnvironmentSummary({ + environment: makeEnvironment({ + name: "Design system polish", + isWorktree: true, + workspaceProvisionType: "managed-worktree", + status: "ready", + }), + host: localEnvironmentDisplayHost, branchName: STORY_BRANCH_NAME, onCreateNewThreadInWorktree: noop, }); @@ -969,6 +1030,33 @@ export function Overview() { environmentSummary={worktreeEnvironmentSummary} />
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ); +} + +export function ProvisioningEnvironmentSummary() { + return ( + + +
+ {provisioningEnvironmentSummary} +
+
+
+ ); +} + +export function WorktreeNamingContract() { + const inputRef = useRef(null); + return ( + + + + + + + +
+ {worktreeEnvironmentSummary} +
+
+
+ ); +} + +export function WorktreeCopyAction() { + return ( + + + + + + ); +} + export function QueuedWorkspace() { return ( diff --git a/apps/app/src/components/promptbox/ThreadEnvironmentSummary.test.tsx b/apps/app/src/components/promptbox/ThreadEnvironmentSummary.test.tsx index ad6f750c7e..32d1db1dc2 100644 --- a/apps/app/src/components/promptbox/ThreadEnvironmentSummary.test.tsx +++ b/apps/app/src/components/promptbox/ThreadEnvironmentSummary.test.tsx @@ -1,27 +1,109 @@ // @vitest-environment jsdom -import { fireEvent, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { TooltipProvider } from "@bb/shared-ui/tooltip"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { ThreadEnvironmentSummary } from "./ThreadEnvironmentSummary"; +afterEach(cleanup); + describe("ThreadEnvironmentSummary", () => { it("uses a host-free environment label in compact prompt boxes", () => { - render( - , + const { container } = render( + + + , ); expect( - document.querySelector('[data-promptbox-full-label=""]')?.textContent, + container.querySelector('[data-promptbox-full-label=""]')?.textContent, ).toBe("Mac Studio · New worktree"); expect( - document.querySelector('[data-promptbox-compact-label=""]')?.textContent, + container.querySelector('[data-promptbox-compact-label=""]')?.textContent, ).toBe("Worktree"); }); + it("reveals the full host and mode when the environment label is constrained", async () => { + const { container } = render( + + + , + ); + + const environmentDisplay = container.querySelector( + '[data-option-display=""]', + ); + expect(environmentDisplay).not.toBeNull(); + expect(environmentDisplay!.className).not.toContain("max-w-[10rem]"); + fireEvent.focus(environmentDisplay!); + + expect((await screen.findByRole("tooltip")).textContent).toBe( + "Bersabel's MacBook Pro", + ); + }); + + it("keeps matching environment and branch labels visibly separate", () => { + render( + + + , + ); + + const copyButton = screen.getByRole("button", { + name: "bb/fix-environment-summary", + }); + expect(screen.getAllByText("bb/fix-environment-summary")).toHaveLength(3); + expect(copyButton.textContent).toBe("bb/fix-environment-summary"); + expect(copyButton.querySelector('[data-icon="GitBranch"]')).not.toBeNull(); + expect(copyButton.querySelector('[data-icon="Copy"]')).toBeNull(); + }); + + it.each(["Local worktree", "Remote worktree", "Local", "Remote"] as const)( + "shows the %s environment type from the environment icon", + async (environmentTypeLabel) => { + render( + + + , + ); + + fireEvent.focus( + screen.getByRole("img", { + name: `Environment type: ${environmentTypeLabel}`, + }), + ); + + expect((await screen.findByRole("tooltip")).textContent).toBe( + environmentTypeLabel, + ); + }, + ); + it("explains the create-thread action in a tooltip", async () => { render( diff --git a/apps/app/src/components/promptbox/ThreadEnvironmentSummary.tsx b/apps/app/src/components/promptbox/ThreadEnvironmentSummary.tsx index cadc8b4851..4f40641ace 100644 --- a/apps/app/src/components/promptbox/ThreadEnvironmentSummary.tsx +++ b/apps/app/src/components/promptbox/ThreadEnvironmentSummary.tsx @@ -3,6 +3,8 @@ import { OptionDisplay } from "@bb/shared-ui/option-display"; import { copyToClipboardWithToast } from "@/lib/clipboard"; import { Icon, type IconName } from "@bb/shared-ui/icon"; import { Tooltip, TooltipContent, TooltipTrigger } from "@bb/shared-ui/tooltip"; +import { cn } from "@bb/shared-ui/lib/utils"; +import type { EnvironmentWorkspaceTypeLabel } from "@/lib/environment-workspace-display"; import type { WorkspaceCheckoutDisplay } from "@/lib/workspace-checkout-display"; const CHECKOUT_CHIP_BASE_CLASS_NAME = @@ -14,6 +16,7 @@ interface ThreadEnvironmentSummaryProps { environmentLabel?: string; environmentCompactLabel?: string; environmentIcon?: IconName; + environmentTypeLabel?: EnvironmentWorkspaceTypeLabel; environmentCheckout?: WorkspaceCheckoutDisplay; onCreateNewThreadInWorktree?: () => void; } @@ -23,10 +26,16 @@ export const ThreadEnvironmentSummary = memo(function ThreadEnvironmentSummary({ environmentLabel, environmentCompactLabel, environmentIcon, + environmentTypeLabel, environmentCheckout, onCreateNewThreadInWorktree, }: ThreadEnvironmentSummaryProps) { - if (!environmentLabel) { + if ( + !projectName && + !environmentLabel && + !environmentCheckout && + !onCreateNewThreadInWorktree + ) { return null; } @@ -39,42 +48,69 @@ export const ThreadEnvironmentSummary = memo(function ThreadEnvironmentSummary({ value={projectName} compactValue={projectName} leading={} - className="h-6 max-w-[10rem] shrink-0" - title={`Project: ${projectName}`} + className="h-6 min-w-0 max-w-[10rem] shrink" + tooltip={`Project: ${projectName}`} muted /> ) : null} - - ) : null - } - className="h-6 max-w-[10rem] shrink-0" - title={`Environment: ${environmentLabel}`} - muted - /> + {environmentLabel ? ( +
+ {environmentIcon && environmentTypeLabel ? ( + + + + + + + {environmentTypeLabel} + + ) : environmentIcon ? ( + + ) : null} + +
+ ) : null} {environmentCheckout && checkoutCopyValue !== null ? ( - + + + + + {environmentCheckout.title} + ) : environmentCheckout ? ( { screen.getByRole("button", { name: "Worktree actions" }), { button: 0 }, ); - fireEvent.click(await screen.findByRole("menuitem", { name: "Rename" })); + fireEvent.click( + await screen.findByRole("menuitem", { name: "Rename worktree" }), + ); expect( - await screen.findByRole("dialog", { name: "Rename environment" }), + await screen.findByRole("dialog", { name: "Rename worktree" }), ).not.toBeNull(); + expect(screen.getByText("feat/menu-close")).not.toBeNull(); await waitFor(() => { - expect(screen.queryByRole("menuitem", { name: "Rename" })).toBeNull(); + expect( + screen.queryByRole("menuitem", { name: "Rename worktree" }), + ).toBeNull(); }); }); }); diff --git a/apps/app/src/components/sidebar/ProjectRow.tsx b/apps/app/src/components/sidebar/ProjectRow.tsx index a3026af04d..a81ad5949b 100644 --- a/apps/app/src/components/sidebar/ProjectRow.tsx +++ b/apps/app/src/components/sidebar/ProjectRow.tsx @@ -871,7 +871,7 @@ function EnvironmentThreadGroupHeaderActions({ }} >