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({
}}
>
- Rename
+ Rename worktree
{
+ it.each([
+ ["managed-worktree", "local", "Local worktree"],
+ ["unmanaged-worktree", "remote", "Remote worktree"],
+ ["other", "local", "Local"],
+ ["other", "remote", "Remote"],
+ ] as const)("maps %s on a %s host to %s", (kind, locality, expected) => {
+ expect(getEnvironmentWorkspaceTypeLabel(kind, locality)).toBe(expected);
+ });
+});
+
+describe("getEnvironmentWorkspaceSummaryDisplay", () => {
+ it("keeps provisioning ahead of host and worktree classification", () => {
+ expect(
+ getEnvironmentWorkspaceSummaryDisplay({
+ display: {
+ modeLabel: "Provisioning",
+ compactModeLabel: "Provisioning",
+ lifecycle: "provisioning",
+ id: "env_test",
+ mode: "direct",
+ workspaceDisplayKind: "managed-worktree",
+ },
+ environmentName: null,
+ locality: "remote",
+ hostName: "Remote builder",
+ }),
+ ).toEqual({
+ label: "Provisioning",
+ compactLabel: "Provisioning",
+ icon: "Loading",
+ typeLabel: undefined,
+ });
+ });
+
+ it("uses the host for a worktree without a custom name", () => {
+ expect(
+ getEnvironmentWorkspaceSummaryDisplay({
+ display: {
+ modeLabel: "Worktree",
+ compactModeLabel: "Worktree",
+ lifecycle: null,
+ id: "env_test",
+ mode: "worktree",
+ workspaceDisplayKind: "managed-worktree",
+ },
+ environmentName: null,
+ locality: "remote",
+ hostName: "Build Mac mini",
+ machinePrefix: "Build Mac mini · ",
+ }),
+ ).toMatchObject({
+ label: "Build Mac mini",
+ compactLabel: "Build Mac mini",
+ icon: "FolderGit",
+ typeLabel: "Remote worktree",
+ });
+ });
+
+ it("preserves a real custom worktree name", () => {
+ expect(
+ getEnvironmentWorkspaceSummaryDisplay({
+ display: {
+ modeLabel: "Design system polish",
+ compactModeLabel: "Design system polish",
+ lifecycle: null,
+ id: "env_test",
+ mode: "worktree",
+ workspaceDisplayKind: "managed-worktree",
+ },
+ environmentName: "Design system polish",
+ locality: "remote",
+ hostName: "Build Mac mini",
+ machinePrefix: "Build Mac mini · ",
+ }),
+ ).toMatchObject({
+ label: "Build Mac mini · Design system polish",
+ compactLabel: "Design system polish",
+ });
+ });
+});
diff --git a/apps/app/src/lib/environment-workspace-display.ts b/apps/app/src/lib/environment-workspace-display.ts
index b9f7ed02ee..0a2b3e7218 100644
--- a/apps/app/src/lib/environment-workspace-display.ts
+++ b/apps/app/src/lib/environment-workspace-display.ts
@@ -1,7 +1,76 @@
import type { EnvironmentWorkspaceDisplayKind } from "@bb/domain";
+import type { EnvironmentDisplayInfo } from "@bb/core-ui";
import type { IconName } from "@bb/shared-ui/icon";
import { PersistentHostIconName } from "@/lib/host-display";
+export type EnvironmentWorkspaceTypeLabel =
+ | "Local worktree"
+ | "Remote worktree"
+ | "Local"
+ | "Remote";
+
+interface EnvironmentWorkspaceSummaryDisplayArgs {
+ display: EnvironmentDisplayInfo;
+ environmentName: string | null;
+ locality: "local" | "remote";
+ hostName?: string;
+ machinePrefix?: string;
+}
+
+export interface EnvironmentWorkspaceSummaryDisplay {
+ label: string | undefined;
+ compactLabel: string | undefined;
+ icon: IconName;
+ typeLabel: EnvironmentWorkspaceTypeLabel | undefined;
+}
+
+export function getEnvironmentWorkspaceSummaryDisplay({
+ display,
+ environmentName,
+ locality,
+ hostName,
+ machinePrefix = "",
+}: EnvironmentWorkspaceSummaryDisplayArgs): EnvironmentWorkspaceSummaryDisplay {
+ if (display.lifecycle === "provisioning") {
+ return {
+ label: "Provisioning",
+ compactLabel: "Provisioning",
+ icon: "Loading",
+ typeLabel: undefined,
+ };
+ }
+
+ return {
+ label:
+ display.mode === "direct"
+ ? hostName
+ : environmentName === null
+ ? hostName
+ : `${machinePrefix}${display.modeLabel}`,
+ compactLabel:
+ display.mode === "direct"
+ ? hostName
+ : environmentName === null
+ ? hostName
+ : display.compactModeLabel,
+ icon: getEnvironmentWorkspaceLabelIconName(display.workspaceDisplayKind),
+ typeLabel: getEnvironmentWorkspaceTypeLabel(
+ display.workspaceDisplayKind,
+ locality,
+ ),
+ };
+}
+
+export function getEnvironmentWorkspaceTypeLabel(
+ kind: EnvironmentWorkspaceDisplayKind,
+ locality: "local" | "remote",
+): EnvironmentWorkspaceTypeLabel {
+ if (kind === "other") {
+ return locality === "local" ? "Local" : "Remote";
+ }
+ return locality === "local" ? "Local worktree" : "Remote worktree";
+}
+
export function getEnvironmentWorkspaceDisplayIconName(
kind: EnvironmentWorkspaceDisplayKind,
): IconName | null {
diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx
index 25d88fed3c..96fdfb4928 100644
--- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx
+++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx
@@ -63,6 +63,7 @@ import {
type QueuedMessageInlineEditor,
} from "@/components/promptbox/banner/QueuedMessagesList";
import { ThreadEnvironmentSummary } from "@/components/promptbox/ThreadEnvironmentSummary";
+import type { EnvironmentWorkspaceTypeLabel } from "@/lib/environment-workspace-display";
import type { WorkspaceCheckoutDisplay } from "@/lib/workspace-checkout-display";
import { useComposerTextEffects } from "@/lib/composer-text-effects";
import { useLatestRef } from "@/hooks/useLatestRef";
@@ -153,6 +154,7 @@ interface ThreadDetailPromptAreaProps {
environmentHostId?: string;
environmentIcon?: IconName;
environmentLabel?: string;
+ environmentTypeLabel?: EnvironmentWorkspaceTypeLabel;
onCreateNewThreadInWorktree?: () => void;
onPullRequestDraft?: () => void;
onPullRequestMerge?: (method: PullRequestMergeMethod) => void;
@@ -344,6 +346,7 @@ export function ThreadDetailPromptArea({
environmentHostId,
environmentIcon,
environmentLabel,
+ environmentTypeLabel,
onCreateNewThreadInWorktree,
onPullRequestDraft,
onPullRequestMerge,
@@ -1174,6 +1177,7 @@ export function ThreadDetailPromptArea({
environmentLabel={environmentLabel}
environmentCompactLabel={environmentCompactLabel}
environmentIcon={environmentIcon}
+ environmentTypeLabel={environmentTypeLabel}
environmentCheckout={environmentCheckout}
onCreateNewThreadInWorktree={onCreateNewThreadInWorktree}
/>
@@ -1183,6 +1187,7 @@ export function ThreadDetailPromptArea({
environmentCompactLabel,
environmentIcon,
environmentLabel,
+ environmentTypeLabel,
onCreateNewThreadInWorktree,
projectName,
],
diff --git a/apps/app/src/views/thread-detail/ThreadDetailView.tsx b/apps/app/src/views/thread-detail/ThreadDetailView.tsx
index 6cc5443e97..4da2acb8ae 100644
--- a/apps/app/src/views/thread-detail/ThreadDetailView.tsx
+++ b/apps/app/src/views/thread-detail/ThreadDetailView.tsx
@@ -105,7 +105,9 @@ import {
useCreateThreadTerminal,
useThreadTerminals,
} from "@/hooks/queries/thread-terminal-queries";
-import { getEnvironmentWorkspaceLabelIconName } from "@/lib/environment-workspace-display";
+import {
+ getEnvironmentWorkspaceSummaryDisplay,
+} from "@/lib/environment-workspace-display";
import { formatWorkspaceCheckoutDisplay } from "@/lib/workspace-checkout-display";
import {
getAbsoluteDirname,
@@ -916,13 +918,14 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) {
),
[hostsQuery.data],
);
- const threadEnvironmentHost = useMemo(() => {
+ const resolvedThreadEnvironmentHost = useMemo(() => {
const hosts = hostsQuery.data ?? [];
- if (hosts.length <= 1) return null;
const environmentHostId = environment?.hostId;
if (!environmentHostId) return null;
return hosts.find((host) => host.id === environmentHostId) ?? null;
}, [environment?.hostId, hostsQuery.data]);
+ const threadEnvironmentHost =
+ (hostsQuery.data?.length ?? 0) > 1 ? resolvedThreadEnvironmentHost : null;
const hostConnectionNotice = useMemo(
() =>
thread
@@ -2341,11 +2344,15 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) {
: undefined;
const environmentMachinePrefix =
threadEnvironmentHost !== null ? `${threadEnvironmentHost.name} · ` : "";
- const threadEnvironmentIcon = threadEnvironmentDisplay
- ? getEnvironmentWorkspaceLabelIconName(
- threadEnvironmentDisplay.workspaceDisplayKind,
- )
- : null;
+ const composerEnvironmentSummary = threadEnvironmentDisplay
+ ? getEnvironmentWorkspaceSummaryDisplay({
+ display: threadEnvironmentDisplay,
+ environmentName: environment?.name ?? null,
+ locality: environmentDisplayHostContext.locality,
+ hostName: resolvedThreadEnvironmentHost?.name,
+ machinePrefix: environmentMachinePrefix,
+ })
+ : undefined;
const isThreadOnProvisionedWorktreeEnvironment =
environment !== undefined &&
environment.status === "ready" &&
@@ -2472,17 +2479,10 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) {
canUseGitUi={canUseGitUi}
contextWindowUsage={contextWindowUsage}
environmentCheckout={threadCheckoutDisplay}
- environmentCompactLabel={
- threadEnvironmentDisplay
- ? threadEnvironmentDisplay.compactModeLabel
- : undefined
- }
- environmentIcon={threadEnvironmentIcon ?? undefined}
- environmentLabel={
- threadEnvironmentDisplay
- ? `${environmentMachinePrefix}${threadEnvironmentDisplay.modeLabel}`
- : undefined
- }
+ environmentCompactLabel={composerEnvironmentSummary?.compactLabel}
+ environmentIcon={composerEnvironmentSummary?.icon}
+ environmentLabel={composerEnvironmentSummary?.label}
+ environmentTypeLabel={composerEnvironmentSummary?.typeLabel}
environmentGoneStatus={threadEnvironmentGoneStatus}
environmentHostId={environment?.hostId}
isEnvironmentActionPending={requestEnvironmentAction.isPending}
diff --git a/packages/core-ui/src/environment-display.ts b/packages/core-ui/src/environment-display.ts
index 2ef30cf317..f9e960f962 100644
--- a/packages/core-ui/src/environment-display.ts
+++ b/packages/core-ui/src/environment-display.ts
@@ -16,6 +16,7 @@ export interface EnvironmentDisplayHostContext {
export interface EnvironmentDisplayInfo {
modeLabel: string;
compactModeLabel: string;
+ lifecycle: "provisioning" | "destroying" | "destroyed" | null;
id: string;
mode: "direct" | "worktree";
workspaceDisplayKind: EnvironmentWorkspaceDisplayKind;
@@ -71,10 +72,18 @@ export function formatEnvironmentDisplay({
: directCompactModeLabel;
const modeLabel = environment.name ?? generatedModeLabel;
const compactModeLabel = environment.name ?? generatedCompactModeLabel;
+ const lifecycle = isProvisioningDisplay
+ ? "provisioning"
+ : environment.status === "destroying"
+ ? "destroying"
+ : environment.status === "destroyed"
+ ? "destroyed"
+ : null;
return {
modeLabel,
compactModeLabel,
+ lifecycle,
id: environment.id,
mode,
workspaceDisplayKind,
diff --git a/packages/core-ui/test/environment-display.test.ts b/packages/core-ui/test/environment-display.test.ts
index 2e59ab2480..8c9096ce1c 100644
--- a/packages/core-ui/test/environment-display.test.ts
+++ b/packages/core-ui/test/environment-display.test.ts
@@ -47,6 +47,7 @@ describe("formatEnvironmentDisplay", () => {
expect(result).toEqual({
modeLabel: "Working locally",
compactModeLabel: "Local",
+ lifecycle: null,
id: "env_test",
mode: "direct",
workspaceDisplayKind: "other",
@@ -61,6 +62,7 @@ describe("formatEnvironmentDisplay", () => {
expect(result).toEqual({
modeLabel: "Working remotely",
compactModeLabel: "Remote",
+ lifecycle: null,
id: "env_test",
mode: "direct",
workspaceDisplayKind: "other",
@@ -78,6 +80,7 @@ describe("formatEnvironmentDisplay", () => {
expect(result).toEqual({
modeLabel: "Worktree",
compactModeLabel: "Worktree",
+ lifecycle: null,
id: "env_test",
mode: "worktree",
workspaceDisplayKind: "managed-worktree",
@@ -175,6 +178,7 @@ describe("formatEnvironmentDisplay", () => {
expect(result).toEqual({
modeLabel: "Provisioning",
compactModeLabel: "Provisioning",
+ lifecycle: "provisioning",
id: "env_test",
mode: "direct",
workspaceDisplayKind: "managed-worktree",
@@ -198,6 +202,7 @@ describe("formatEnvironmentDisplay", () => {
expect(result).toEqual({
modeLabel: "Provisioning",
compactModeLabel: "Provisioning",
+ lifecycle: "provisioning",
id: "env_test",
mode: "direct",
workspaceDisplayKind: "other",
diff --git a/packages/shared-ui/src/components/ui/option-display.tsx b/packages/shared-ui/src/components/ui/option-display.tsx
index e95ba0f4d2..93eab47a39 100644
--- a/packages/shared-ui/src/components/ui/option-display.tsx
+++ b/packages/shared-ui/src/components/ui/option-display.tsx
@@ -1,5 +1,6 @@
import type { ComponentType, ReactNode } from "react";
import { cn } from "../../lib/utils";
+import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip";
export const OPTION_BASE_CLASS_NAME =
"h-8 w-fit max-w-full min-w-0 items-center justify-start gap-1 px-1 text-xs leading-tight";
@@ -21,6 +22,7 @@ export interface OptionDisplayProps {
compactValueHiddenWhenTiny?: boolean;
className?: string;
title?: string;
+ tooltip?: ReactNode;
muted?: boolean;
}
@@ -34,18 +36,22 @@ export function OptionDisplay({
compactValueHiddenWhenTiny,
className,
title,
+ tooltip,
muted,
}: OptionDisplayProps) {
const defaultTitle =
typeof value === "string" ? `${label}: ${value}` : undefined;
- return (
+ const display = (
);
+
+ if (!tooltip) {
+ return display;
+ }
+
+ return (
+
+ {display}
+ {tooltip}
+
+ );
}