Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion apps/app/src/components/pickers/EnvironmentPicker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -265,10 +265,18 @@ describe("EnvironmentPickerUI multi-machine menu", () => {
expect(placeholder.getAttribute("aria-disabled")).toBe("true");
});

it("names a non-primary machine in the trigger label", () => {
it("names the primary machine in the trigger label when multiple machines exist", () => {
renderMachineMenu({ value: `host:${thisMachine.id}:worktree` });

expect(screen.getByText("MacBook Pro · New worktree")).toBeTruthy();
expect(screen.getByText("Worktree")).toBeTruthy();
});

it("names another selected machine in the trigger label", () => {
renderMachineMenu({ value: `host:${studio.id}:worktree` });

expect(screen.getByText("Mac Studio · New worktree")).toBeTruthy();
expect(screen.getByText("Worktree")).toBeTruthy();
});

it("keeps the single-host menu when only one host exists", () => {
Expand Down
25 changes: 12 additions & 13 deletions apps/app/src/components/pickers/EnvironmentPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import {
} from "@bb/shared-ui/coarse-pointer-sizing";
import { LIST_HOVER_TRANSITION } from "@bb/shared-ui/motion";
import { MachineStatusDot } from "@/components/machines/MachineStatusDot";
import { selectPrimaryHost } from "@/hooks/queries/host-queries";
import { getEnvironmentWorkspaceLabelIconName } from "@/lib/environment-workspace-display";
import { formatRelativeTime } from "@/lib/relative-time";
import { formatHostUpdateStatus } from "@/lib/host-update-status";
Expand Down Expand Up @@ -145,16 +144,11 @@ export function EnvironmentPickerUI({

const parsed = useMemo(() => parseEnvironmentValue(value), [value]);

// Mockup A: the composer chip names the machine whenever the selection
// isn't on the primary host ("Mac Studio · New worktree").
// When the server knows multiple machines, name the selected one in the
// full composer chip ("Mac Studio · New worktree"). Single-machine and
// compact layouts use the shorter mode-only label.
const selectedMachineName = useMemo(() => {
if (!isMachineMenu || !machines || parsed?.type !== "host") return null;
if (
parsed.hostId ===
selectPrimaryHost(machines.hosts, machines.primaryHostId)?.id
) {
return null;
}
return (
machines.hosts.find((machineHost) => machineHost.id === parsed.hostId)
?.name ?? null
Expand Down Expand Up @@ -202,7 +196,14 @@ export function EnvironmentPickerUI({
compactModeLabel,
icon,
};
}, [parsed, localLabel, isLocal, hostUnavailableReason, host, selectedMachineName]);
}, [
parsed,
localLabel,
isLocal,
hostUnavailableReason,
host,
selectedMachineName,
]);

return (
<DropdownMenu defaultOpen={defaultOpen} modal={modal}>
Expand Down Expand Up @@ -589,9 +590,7 @@ function EnvironmentMenuItem({
)}
/>
<span className="flex min-w-0 flex-col">
<span className="whitespace-normal break-words text-xs">
{label}
</span>
<span className="whitespace-normal break-words text-xs">{label}</span>
{description ? (
<span className="mt-0.5 whitespace-normal break-words text-xs leading-snug text-muted-foreground">
{description}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,22 @@ import { describe, expect, it, vi } from "vitest";
import { ThreadEnvironmentSummary } from "./ThreadEnvironmentSummary";

describe("ThreadEnvironmentSummary", () => {
it("uses a host-free environment label in compact prompt boxes", () => {
render(
<ThreadEnvironmentSummary
environmentLabel="Mac Studio · New worktree"
environmentCompactLabel="Worktree"
/>,
);

expect(
document.querySelector('[data-promptbox-full-label=""]')?.textContent,
).toBe("Mac Studio · New worktree");
expect(
document.querySelector('[data-promptbox-compact-label=""]')?.textContent,
).toBe("Worktree");
});

it("explains the create-thread action in a tooltip", async () => {
render(
<TooltipProvider delayDuration={0}>
Expand Down
13 changes: 6 additions & 7 deletions apps/app/src/components/promptbox/ThreadEnvironmentSummary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ const CHECKOUT_CHIP_BUTTON_CLASS_NAME = `${CHECKOUT_CHIP_BASE_CLASS_NAME} cursor
export interface ThreadEnvironmentSummaryProps {
/** Display name of the thread's project, shown alongside the environment. */
projectName?: string;
/** Full mode label used for the title (e.g. "Working locally" / "Worktree"). */
/** Full mode label used on larger prompt boxes and in the title. */
environmentLabel?: string;
/** Visible label used in the promptbox footer. */
/** Short label used when the promptbox switches to its compact layout. */
environmentCompactLabel?: string;
/** Icon for the environment (e.g. monitor / git branch). */
environmentIcon?: IconName;
Expand All @@ -36,7 +36,8 @@ export interface ThreadEnvironmentSummaryProps {
* Read-only — environment editing happens elsewhere.
*
* Responsive behavior:
* - The visible environment label always uses the compact display string.
* - The full environment label is replaced by the compact display string in
* narrow promptbox shells.
* - The summary can shrink inside the follow-up strip so permission/context
* controls stay pinned and text truncates instead of wrapping.
* - Branch chip hides only in very narrow promptbox shells and truncates
Expand All @@ -55,8 +56,6 @@ export const ThreadEnvironmentSummary = memo(function ThreadEnvironmentSummary({
}

const checkoutCopyValue = environmentCheckout?.copyValue ?? null;
const visibleEnvironmentLabel = environmentCompactLabel ?? environmentLabel;

return (
<div className="flex min-w-0 max-w-full items-center gap-2 pr-1.5">
{projectName ? (
Expand All @@ -72,8 +71,8 @@ export const ThreadEnvironmentSummary = memo(function ThreadEnvironmentSummary({
) : null}
<OptionDisplay
label="Environment"
value={visibleEnvironmentLabel}
compactValue={visibleEnvironmentLabel}
value={environmentLabel}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — The wide follow-up label truncates.

value now receives a host-prefixed label. The chip still has a 10-rem maximum width.

In the two-host browser test, the text needed 189 pixels but received 130 pixels. The compact label displayed correctly at 390 pixels.

Please allow more width or preserve the mode when the host name truncates. The new unit test checks text nodes only.

compactValue={environmentCompactLabel}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — The optional compact label can disappear.

environmentCompactLabel remains optional. When a caller omits it, OptionDisplay omits the compact span.

The container CSS hides the full span below 34 rem. The environment label then becomes empty.

Restore environmentCompactLabel ?? environmentLabel, or require the compact label.

leading={
environmentIcon ? (
<Icon name={environmentIcon} className="size-4 shrink-0" />
Expand Down
19 changes: 6 additions & 13 deletions apps/app/src/views/thread-detail/ThreadDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ import { assertNever } from "@bb/thread-view";
import { useCreateThreadInWorktree } from "@/hooks/useCreateThreadInWorktree";
import { useHostDaemon } from "@/hooks/useHostDaemon";
import { useLocalOpenTargets } from "@/hooks/useLocalOpenTargets";
import { selectPrimaryHost, useHosts } from "@/hooks/queries/host-queries";
import { useHosts } from "@/hooks/queries/host-queries";
import { useSystemConfig } from "@/hooks/queries/system-queries";
import { useConnectionAwareQueryState } from "@/hooks/queries/connection-aware-query-state";
import {
Expand Down Expand Up @@ -2430,18 +2430,11 @@ function ThreadDetailViewInternal(props: ThreadDetailViewInternalProps) {
host: environmentDisplayHostContext,
})
: undefined;
// The follow-up composer chip names the machine when the thread doesn't run
// on the primary host ("Mac Studio · Worktree") — mirrors the new-thread
// composer chip.
// `threadEnvironmentHost` is populated only when the server knows multiple
// machines, so name that machine in the full follow-up composer label in
// exactly that case. Compact layouts use the host-free compact label.
const environmentMachinePrefix =
threadEnvironmentHost !== null &&
threadEnvironmentHost.id !==
selectPrimaryHost(
hostsQuery.data,
systemConfigQuery.data?.primaryHostId ?? null,
)?.id
? `${threadEnvironmentHost.name} · `
: "";
threadEnvironmentHost !== null ? `${threadEnvironmentHost.name} · ` : "";
const threadEnvironmentIcon = threadEnvironmentDisplay
? getEnvironmentWorkspaceLabelIconName(
threadEnvironmentDisplay.workspaceDisplayKind,
Expand Down Expand Up @@ -2579,7 +2572,7 @@ function ThreadDetailViewInternal(props: ThreadDetailViewInternalProps) {
environmentCheckout={threadCheckoutDisplay}
environmentCompactLabel={
threadEnvironmentDisplay
? `${environmentMachinePrefix}${threadEnvironmentDisplay.compactModeLabel}`
? threadEnvironmentDisplay.compactModeLabel
: undefined
}
environmentIcon={threadEnvironmentIcon ?? undefined}
Expand Down
1 change: 0 additions & 1 deletion apps/mobile/src/screens/compose/ComposeDock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,6 @@ function WhereControls({
onChange={c.setEnvironment}
host={c.selectedHost}
hostHasSource={c.hostHasSource}
primaryHostId={c.primaryHostId}
isPersonalProject={c.isPersonalProject}
reuseOptions={c.reuseOptions}
reuseOptionsLoading={c.reuseOptionsLoading}
Expand Down
70 changes: 5 additions & 65 deletions apps/mobile/src/screens/pickers/EnvironmentPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import {
} from "@/ui";
import { usePickerSheetMaxHeight } from "./OptionSheet";
import { PickerTrigger } from "./PickerTrigger";
import { describeEnvironmentSelection } from "./environment-picker-model";

export { describeEnvironmentSelection } from "./environment-picker-model";

/** The mode rows the picker offers; reuse rows carry their environment id. */
export type EnvironmentPickerMode =
Expand All @@ -34,8 +37,6 @@ export interface EnvironmentPickerProps {
host: Host | null;
/** Whether that machine holds a checkout of the project (always for personal). */
hostHasSource: boolean;
/** The server's primary host; the pill names any other machine explicitly. */
primaryHostId?: string | null;
isPersonalProject: boolean;
reuseOptions: readonly ReuseEnvironmentOption[];
reuseOptionsLoading: boolean;
Expand All @@ -45,65 +46,6 @@ export interface EnvironmentPickerProps {
testID?: string;
}

interface SelectedSummary {
label: string;
icon: IconName;
tone: "default" | "warning";
}

export function describeEnvironmentSelection(
value: ThreadEnvironmentSelection,
host: Host | null,
reuseOptions: readonly ReuseEnvironmentOption[],
/** Name the machine in the label only when it is not the primary host. */
primaryHostId: string | null = null,
): SelectedSummary {
switch (value.type) {
case "project-default":
return { label: "Project default", icon: "Laptop", tone: "default" };
case "reuse": {
const option = reuseOptions.find(
(candidate) => candidate.environmentId === value.environmentId,
);
const name = option?.name ?? option?.branchName;
return {
label: name ? `Reuse ${name}` : "Reuse worktree",
icon: "FolderGit",
tone: "default",
};
}
case "host": {
const offline = host !== null && host.status !== "connected";
const machine =
host !== null && host.id !== primaryHostId ? host.name : undefined;
if (value.workspace.type === "managed-worktree") {
return {
label: machine ? `${machine} · New worktree` : "New worktree",
icon: "FolderGit",
tone: offline ? "warning" : "default",
};
}
if (value.workspace.type === "personal") {
return {
label: machine ? `${machine} · Personal` : "Personal workspace",
icon: "Laptop",
tone: offline ? "warning" : "default",
};
}
const custom = value.workspace.path;
return {
label: custom
? `${machine ? `${machine} · ` : ""}${custom}`
: machine
? `${machine} · Checkout`
: "Work in checkout",
icon: "Folder",
tone: offline ? "warning" : "default",
};
}
}
}

/**
* Environment (where the thread runs) picker: project default (server
* policy), work in the project checkout on a machine, a new managed
Expand All @@ -115,7 +57,6 @@ export function EnvironmentPicker({
onChange,
host,
hostHasSource,
primaryHostId = null,
isPersonalProject,
reuseOptions,
reuseOptionsLoading,
Expand All @@ -127,9 +68,8 @@ export function EnvironmentPicker({
const { tokens } = useTheme();
const maxHeight = usePickerSheetMaxHeight();
const summary = useMemo(
() =>
describeEnvironmentSelection(value, host, reuseOptions, primaryHostId),
[host, primaryHostId, reuseOptions, value],
() => describeEnvironmentSelection(value, host, reuseOptions),
[host, reuseOptions, value],
);
const hostUnavailableReason =
host === null
Expand Down
30 changes: 30 additions & 0 deletions apps/mobile/src/screens/pickers/environment-picker-model.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { Host } from "@bb/domain";
import { describe, expect, it } from "vitest";
import type { ThreadEnvironmentSelection } from "@/data/compose";
import { describeEnvironmentSelection } from "./environment-picker-model";

const host: Host = {
id: "host_primary",
name: "MacBook Pro",
type: "persistent",
status: "connected",
lastSeenAt: null,
maxPermissionMode: "full",
lastRejectedProtocolVersion: null,
createdAt: 0,
updatedAt: 0,
};

const worktreeSelection: ThreadEnvironmentSelection = {
type: "host",
hostId: host.id,
workspace: { type: "managed-worktree", baseBranch: null },
};

describe("describeEnvironmentSelection", () => {
it("omits the machine name from the mobile environment label", () => {
expect(describeEnvironmentSelection(worktreeSelection, host, []).label).toBe(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — Prettier rejects this new test file.

pnpm exec prettier --check apps/mobile/src/screens/pickers/environment-picker-model.test.ts reports a style error here.

Please format this test.

"New worktree",
);
});
});
56 changes: 56 additions & 0 deletions apps/mobile/src/screens/pickers/environment-picker-model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { Host } from "@bb/domain";
import type {
ReuseEnvironmentOption,
ThreadEnvironmentSelection,
} from "@/data/compose";

export interface EnvironmentSelectionSummary {
label: string;
icon: "Laptop" | "FolderGit" | "Folder";
tone: "default" | "warning";
}

export function describeEnvironmentSelection(
value: ThreadEnvironmentSelection,
host: Host | null,
reuseOptions: readonly ReuseEnvironmentOption[],
): EnvironmentSelectionSummary {
switch (value.type) {
case "project-default":
return { label: "Project default", icon: "Laptop", tone: "default" };
case "reuse": {
const option = reuseOptions.find(
(candidate) => candidate.environmentId === value.environmentId,
);
const name = option?.name ?? option?.branchName;
return {
label: name ? `Reuse ${name}` : "Reuse worktree",
icon: "FolderGit",
tone: "default",
};
}
case "host": {
const offline = host !== null && host.status !== "connected";
if (value.workspace.type === "managed-worktree") {
return {
label: "New worktree",
icon: "FolderGit",
tone: offline ? "warning" : "default",
};
}
if (value.workspace.type === "personal") {
return {
label: "Personal workspace",
icon: "Laptop",
tone: offline ? "warning" : "default",
};
}
const custom = value.workspace.path;
return {
label: custom ?? "Work in checkout",
icon: "Folder",
tone: offline ? "warning" : "default",
};
}
}
}