diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4589f46abd..774f0c1ebc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -138,6 +138,16 @@ jobs: pnpm-version: ${{ env.PNPM_VERSION }} cache-prefix: test-${{ matrix.shard }} + # The @bb/host-workspace jj suites skip when the binary is absent, so + # without this step they would silently never run in CI. + - name: Install jj (Jujutsu) for colocated-repo tests + if: matrix.shard == 'packages' + run: | + mkdir -p "$HOME/.local/bin" + curl -fsSL https://github.com/jj-vcs/jj/releases/download/v0.44.0/jj-v0.44.0-x86_64-unknown-linux-musl.tar.gz \ + | tar -xz -C "$HOME/.local/bin" ./jj + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + - name: Test run: pnpm exec turbo run test ${{ matrix.filter }} --cache-dir=.turbo/cache --output-logs=new-only ${{ matrix.args }} diff --git a/apps/app/.ladle/story-fixtures.ts b/apps/app/.ladle/story-fixtures.ts index 68258ebc56..9622622d20 100644 --- a/apps/app/.ladle/story-fixtures.ts +++ b/apps/app/.ladle/story-fixtures.ts @@ -391,6 +391,7 @@ export function makeThreadListEntry( environmentName: null, environmentBranchName: null, environmentWorkspaceDisplayKind: "other", + environmentVcs: null, runtime: { displayStatus: "idle", hostReconnectGraceExpiresAt: null }, }; return { ...base, ...overrides }; @@ -466,6 +467,7 @@ export function makeEnvironment( managed: true, isGitRepo: true, isWorktree: true, + vcs: null, workspaceProvisionType: "managed-worktree", branchName: BRANCH_NAMES.feature, baseBranch: BRANCH_NAMES.default, diff --git a/apps/app/src/components/commands/ThreadPaletteResults.test.tsx b/apps/app/src/components/commands/ThreadPaletteResults.test.tsx index 5781c2cdb6..8ec6603e9e 100644 --- a/apps/app/src/components/commands/ThreadPaletteResults.test.tsx +++ b/apps/app/src/components/commands/ThreadPaletteResults.test.tsx @@ -57,6 +57,7 @@ function createThreadListEntry({ environmentId: null, environmentName: null, environmentWorkspaceDisplayKind: "other", + environmentVcs: null, hasPendingInteraction: false, id, lastReadAt: null, diff --git a/apps/app/src/components/pickers/EnvironmentPicker.tsx b/apps/app/src/components/pickers/EnvironmentPicker.tsx index f5c2823297..464fa618e5 100644 --- a/apps/app/src/components/pickers/EnvironmentPicker.tsx +++ b/apps/app/src/components/pickers/EnvironmentPicker.tsx @@ -1,7 +1,10 @@ import { useMemo } from "react"; -import type { Host, ProjectSource } from "@bb/domain"; +import type { Host, ProjectSource, WorkspaceVcs } from "@bb/domain"; import { Icon, type IconName } from "@bb/shared-ui/icon"; -import { findLocalPathProjectSourceForHost } from "@bb/domain"; +import { + findLocalPathProjectSourceForHost, + managedCheckoutNoun, +} from "@bb/domain"; import { Button } from "@bb/shared-ui/button"; import { DropdownMenu, @@ -78,6 +81,11 @@ export interface EnvironmentPickerUIProps { reuseDisabled?: boolean; /** Reason to disable "New worktree" while leaving local/remote work usable. */ worktreeDisabledReason?: string | null; + /** + * Which tool owns the project source's checkout. jj sources get workspaces + * rather than worktrees, and the picker names them that way. + */ + vcs?: WorkspaceVcs | null; /** Render with the dim, hover-to-foreground treatment used inside the prompt box. */ muted?: boolean; /** Render as a non-interactive label while preserving the selected mode. */ @@ -104,6 +112,7 @@ export function EnvironmentPickerUI({ isLocal, reuseDisabled, worktreeDisabledReason, + vcs, muted, disabled = false, className, @@ -112,6 +121,11 @@ export function EnvironmentPickerUI({ machines, onRequestMachineSetup, }: EnvironmentPickerUIProps) { + const checkoutNoun = managedCheckoutNoun(vcs); + const checkoutNounCapitalized = managedCheckoutNoun(vcs, { + capitalized: true, + }); + const checkoutNounPlural = managedCheckoutNoun(vcs, { plural: true }); const hostId = host?.id ?? null; const isMachineMenu = (machines?.hosts.length ?? 0) > 1; const hostConnected = host?.status === "connected"; @@ -139,7 +153,7 @@ export function EnvironmentPickerUI({ const newWorktreeDisabledReason = workspaceDisabledReason ?? worktreeDisabledReason ?? null; const reuseDisabledReason = reuseDisabled - ? "No worktrees in this project yet" + ? `No ${checkoutNounPlural} in this project yet` : null; const parsed = useMemo(() => parseEnvironmentValue(value), [value]); @@ -178,14 +192,19 @@ export function EnvironmentPickerUI({ } if (parsed.type === "reuse") { return { - modeLabel: "Reuse worktree", + modeLabel: `Reuse ${checkoutNoun}`, compactModeLabel: "Reuse", icon: getEnvironmentWorkspaceLabelIconName("managed-worktree"), }; } - const modeLabel = parsed.mode === "worktree" ? "New worktree" : localLabel; + const modeLabel = + parsed.mode === "worktree" ? `New ${checkoutNoun}` : localLabel; const compactModeLabel = - parsed.mode === "worktree" ? "Worktree" : isLocal ? "Local" : "Remote"; + parsed.mode === "worktree" + ? checkoutNounCapitalized + : isLocal + ? "Local" + : "Remote"; const icon = getEnvironmentWorkspaceLabelIconName( parsed.mode === "worktree" ? "managed-worktree" : "other", ); @@ -197,6 +216,8 @@ export function EnvironmentPickerUI({ icon, }; }, [ + checkoutNoun, + checkoutNounCapitalized, parsed, localLabel, isLocal, @@ -262,6 +283,7 @@ export function EnvironmentPickerUI({ sources={sources} selectedHostId={parsed?.type === "host" ? parsed.hostId : hostId} worktreeDisabledReason={worktreeDisabledReason ?? null} + vcs={vcs} reuseDisabledReason={reuseDisabledReason} selectedType={parsed?.type} value={value} @@ -276,6 +298,7 @@ export function EnvironmentPickerUI({ localLabel={localLabel} workspaceDisabledReason={workspaceDisabledReason} worktreeDisabledReason={newWorktreeDisabledReason} + vcs={vcs} reuseDisabledReason={reuseDisabledReason} selectedType={parsed?.type} value={value} @@ -300,6 +323,8 @@ interface EnvironmentOptionsSectionProps { workspaceDisabledReason: string | null; /** Why the worktree option is unavailable, or null when usable. */ worktreeDisabledReason: string | null; + /** Which tool owns the source checkout, so rows name it correctly. */ + vcs: WorkspaceVcs | null | undefined; /** Why the reuse option is unavailable, or null when usable. */ reuseDisabledReason: string | null; selectedType: @@ -316,11 +341,13 @@ function EnvironmentOptionsSection({ localLabel, workspaceDisabledReason, worktreeDisabledReason, + vcs, reuseDisabledReason, selectedType, value, onChange, }: EnvironmentOptionsSectionProps) { + const checkoutNoun = managedCheckoutNoun(vcs); const localValue = hostId ? encodeHostValue(hostId, "local") : null; const worktreeValue = hostId ? encodeHostValue(hostId, "worktree") : null; const workspaceDisabled = workspaceDisabledReason !== null; @@ -355,7 +382,7 @@ function EnvironmentOptionsSection({ }} /> >["type"] @@ -406,6 +435,7 @@ function MachineGroupedEnvironmentOptions({ sources, selectedHostId, worktreeDisabledReason, + vcs, reuseDisabledReason, selectedType, value, @@ -432,6 +462,7 @@ function MachineGroupedEnvironmentOptions({ worktreeDisabledReason={ machineHost.id === selectedHostId ? worktreeDisabledReason : null } + vcs={machineHost.id === selectedHostId ? vcs : null} now={now} value={value} onChange={onChange} @@ -441,7 +472,7 @@ function MachineGroupedEnvironmentOptions({ void; @@ -471,6 +504,7 @@ function MachineSection({ isThisMachine, source, worktreeDisabledReason, + vcs, now, value, onChange, @@ -511,7 +545,7 @@ function MachineSection({ onSelect={() => onChange(localValue)} /> @@ -149,14 +153,16 @@ export const ThreadEnvironmentSummary = memo(function ThreadEnvironmentSummary({ - Create new thread in this worktree + + {`Create new thread in this ${checkoutNoun}`} + ) : null} diff --git a/apps/app/src/components/secondary-panel/ThreadMetadataContent.rows.stories.tsx b/apps/app/src/components/secondary-panel/ThreadMetadataContent.rows.stories.tsx index 566ec1aba6..dbc69c9dbe 100644 --- a/apps/app/src/components/secondary-panel/ThreadMetadataContent.rows.stories.tsx +++ b/apps/app/src/components/secondary-panel/ThreadMetadataContent.rows.stories.tsx @@ -144,6 +144,7 @@ export function Environment() { @@ -156,6 +157,7 @@ export function Environment() { isWorktree: false, workspaceProvisionType: "unmanaged", })} + environmentCheckout={null} environmentDisplayHost={localEnvironmentDisplayHost} /> @@ -168,6 +170,7 @@ export function Environment() { isWorktree: false, workspaceProvisionType: "unmanaged", })} + environmentCheckout={null} environmentDisplayHost={remoteEnvironmentDisplayHost} /> @@ -181,6 +184,7 @@ export function Environment() { isWorktree: false, workspaceProvisionType: "managed-worktree", })} + environmentCheckout={null} environmentDisplayHost={localEnvironmentDisplayHost} /> @@ -287,6 +291,40 @@ export function Branch() { /> + + + + + + + + + + ); } diff --git a/apps/app/src/components/secondary-panel/ThreadMetadataContent.test.tsx b/apps/app/src/components/secondary-panel/ThreadMetadataContent.test.tsx index 4d5ad9d900..34ee54a310 100644 --- a/apps/app/src/components/secondary-panel/ThreadMetadataContent.test.tsx +++ b/apps/app/src/components/secondary-panel/ThreadMetadataContent.test.tsx @@ -47,6 +47,7 @@ function makeEnvironment(overrides: Partial = {}): Environment { managed: true, isGitRepo: true, isWorktree: true, + vcs: null, workspaceProvisionType: "managed-worktree", branchName: "feature", baseBranch: "main", @@ -66,6 +67,7 @@ function renderEnvironmentRow(environment: Environment): string { @@ -89,6 +91,7 @@ describe("EnvironmentRow", () => { diff --git a/apps/app/src/components/secondary-panel/ThreadMetadataContent.tsx b/apps/app/src/components/secondary-panel/ThreadMetadataContent.tsx index b099b6bac6..91711149f6 100644 --- a/apps/app/src/components/secondary-panel/ThreadMetadataContent.tsx +++ b/apps/app/src/components/secondary-panel/ThreadMetadataContent.tsx @@ -4,6 +4,7 @@ import type { ThreadStorageBrowserController } from "./useThreadStorageBrowser"; import { Link } from "react-router-dom"; import type { Environment, + GitCheckoutRef, GitBranchRefClassification, Thread, ThreadListEntry, @@ -75,6 +76,7 @@ import { } from "@/components/pull-request/PullRequestStatusPill"; import { GithubFaviconIcon } from "@/components/pull-request/GithubFaviconIcon"; import { useUrlAnchorClickHandler } from "@/lib/url-open-routing"; +import { managedCheckoutNoun, resolveWorkspaceVcs } from "@bb/domain"; // --------------------------------------------------------------------------- // Each row of the Info tab is a function component that owns its own raw @@ -293,12 +295,18 @@ function ForksRow({ thread, projectId }: ForksRowProps) { interface EnvironmentRowProps { thread: Thread; environment: Environment | null; + /** + * Live checkout, when known. Only used to recognize a jj workspace whose + * environment row predates bb recording which tool owns it. + */ + environmentCheckout: GitCheckoutRef | null; environmentDisplayHost: EnvironmentDisplayHostContext; } export function EnvironmentRow({ thread, environment, + environmentCheckout, environmentDisplayHost, }: EnvironmentRowProps) { const createThreadInWorktree = useCreateThreadInWorktree({ @@ -308,8 +316,15 @@ export function EnvironmentRow({ if (!environment) return null; const display = formatEnvironmentDisplay({ environment, + checkout: environmentCheckout, host: environmentDisplayHost, }); + const checkoutNoun = managedCheckoutNoun( + resolveWorkspaceVcs({ + vcs: environment.vcs, + checkout: environmentCheckout, + }), + ); const showCreateThreadButton = isProvisionedWorktreeEnvironment(environment); return ( - Create new thread in this worktree + + {`Create new thread in this ${checkoutNoun}`} + ) : null} @@ -1046,6 +1063,7 @@ export function ThreadMetadataContent(props: ThreadMetadataContentProps) { diff --git a/apps/app/src/components/secondary-panel/git-diff/useEnvironmentMergeBase.test.ts b/apps/app/src/components/secondary-panel/git-diff/useEnvironmentMergeBase.test.ts index 03b284ee56..c6ceed5258 100644 --- a/apps/app/src/components/secondary-panel/git-diff/useEnvironmentMergeBase.test.ts +++ b/apps/app/src/components/secondary-panel/git-diff/useEnvironmentMergeBase.test.ts @@ -20,6 +20,7 @@ function makeEnvironment(overrides: EnvironmentOverrides = {}): Environment { name: null, isGitRepo: true, isWorktree: true, + vcs: null, managed: true, mergeBaseBranch: null, path: "/tmp/workspace", diff --git a/apps/app/src/components/secondary-panel/git-diff/useGitDiffPanel.test.tsx b/apps/app/src/components/secondary-panel/git-diff/useGitDiffPanel.test.tsx index 4df2cd8de5..21d77ddba3 100644 --- a/apps/app/src/components/secondary-panel/git-diff/useGitDiffPanel.test.tsx +++ b/apps/app/src/components/secondary-panel/git-diff/useGitDiffPanel.test.tsx @@ -61,6 +61,7 @@ function makeEnvironment(id: string, mergeBaseBranch: string): Environment { name: null, isGitRepo: true, isWorktree: true, + vcs: null, managed: true, mergeBaseBranch, path: `/tmp/${id}`, diff --git a/apps/app/src/components/sidebar/ProjectList.modes.test.tsx b/apps/app/src/components/sidebar/ProjectList.modes.test.tsx index e034107b7d..5e95900541 100644 --- a/apps/app/src/components/sidebar/ProjectList.modes.test.tsx +++ b/apps/app/src/components/sidebar/ProjectList.modes.test.tsx @@ -141,6 +141,7 @@ function makeThread(overrides: Partial = {}): ThreadListEntry { environmentName: null, environmentBranchName: null, environmentWorkspaceDisplayKind: "other", + environmentVcs: null, runtime: { displayStatus: "active", hostReconnectGraceExpiresAt: null, diff --git a/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx b/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx index 09e37a21c0..7bdfac5bce 100644 --- a/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx +++ b/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx @@ -124,6 +124,7 @@ function makeThread(overrides: Partial = {}): ThreadListEntry { environmentName: null, environmentBranchName: null, environmentWorkspaceDisplayKind: "other", + environmentVcs: null, runtime: { displayStatus: "idle", hostReconnectGraceExpiresAt: null }, ...overrides, }; diff --git a/apps/app/src/components/sidebar/ProjectRow.tsx b/apps/app/src/components/sidebar/ProjectRow.tsx index c59648f214..141e34a4b6 100644 --- a/apps/app/src/components/sidebar/ProjectRow.tsx +++ b/apps/app/src/components/sidebar/ProjectRow.tsx @@ -14,7 +14,11 @@ import { verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { createPortal } from "react-dom"; -import { PERSONAL_PROJECT_ID, type ThreadListEntry } from "@bb/domain"; +import { + managedCheckoutNoun, + PERSONAL_PROJECT_ID, + type ThreadListEntry, +} from "@bb/domain"; import type { ProjectResponse } from "@bb/server-contract"; import { NavLink } from "react-router-dom"; import { useCreateThreadInWorktree } from "@/hooks/useCreateThreadInWorktree"; @@ -407,6 +411,7 @@ interface EnvironmentThreadGroupHeaderProps { } interface EnvironmentThreadGroupHeaderActionsProps { + checkoutNoun: string; archiveThreadsPending: boolean; onArchiveThreads: () => void; onCreateNewThread: () => void; @@ -852,6 +857,7 @@ function useEnvironmentThreadGroupRenameAction({ } function EnvironmentThreadGroupHeaderActions({ + checkoutNoun, archiveThreadsPending, onArchiveThreads, onCreateNewThread, @@ -866,7 +872,7 @@ function EnvironmentThreadGroupHeaderActions({ type="button" variant="ghost" size="icon" - aria-label="Worktree actions" + aria-label={`${checkoutNoun} actions`} className={cn( "rounded-md p-0 text-muted-foreground", "data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-foreground", @@ -903,7 +909,7 @@ function EnvironmentThreadGroupHeaderActions({ }} >