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
45 changes: 45 additions & 0 deletions .github/actions/setup-jj/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: Setup Jujutsu
description: Install the jj CLI from the official jj-vcs release artifacts.

inputs:
version:
description: Jujutsu release tag to install.
required: false
default: v0.42.0

runs:
using: composite
steps:
- name: Install jj
shell: bash
env:
JJ_VERSION: ${{ inputs.version }}
run: | # zizmor: ignore[github-env] -- runner-owned path and validated version
set -euo pipefail

if [[ ! "$JJ_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Invalid jj release tag" >&2
exit 1
fi

case "${RUNNER_OS}-${RUNNER_ARCH}" in
Linux-X64)
asset="jj-${JJ_VERSION}-x86_64-unknown-linux-musl.tar.gz"
;;
*)
echo "Unsupported runner for jj install: ${RUNNER_OS}-${RUNNER_ARCH}" >&2
exit 1
;;
esac

install_dir="${RUNNER_TEMP}/jj-${JJ_VERSION}"
mkdir -p "$install_dir"

curl -fsSL \
"https://github.com/jj-vcs/jj/releases/download/${JJ_VERSION}/${asset}" \
-o "${RUNNER_TEMP}/${asset}"
tar -xzf "${RUNNER_TEMP}/${asset}" -C "$install_dir"

chmod +x "${install_dir}/jj"
echo "$install_dir" >> "$GITHUB_PATH"
"${install_dir}/jj" --version
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,10 @@ jobs:
cache: true
run-install: true

# The jj suites in apps/server/src/vcs skip themselves when jj is absent,
# so this step is what makes them actually run.
- uses: ./.github/actions/setup-jj

# No Electron setup here: `t3` (apps/server) has no Electron dependency
# and none of its tests touch the runtime. Only the non-server `test`
# job, which covers @t3tools/desktop, needs the download.
Expand Down
23 changes: 21 additions & 2 deletions apps/mobile/src/features/review/ReviewSheet.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { getVcsTerminology, type VcsTerminology } from "@t3tools/shared/vcs";
import { useEnvironmentQuery } from "../../state/query";
import { vcsEnvironment } from "../../state/vcs";
import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
Expand Down Expand Up @@ -71,6 +74,7 @@ import { reportShowcaseSceneRendered } from "../showcase/showcaseRenderSignal";
function ReviewHeader(
props: Parameters<typeof useReviewHeaderPresentation>[0] & {
readonly iconColor: string;
readonly vcsTerminology: VcsTerminology;
readonly sectionMenu: ReturnType<typeof buildReviewSectionMenu>;
readonly showSectionToolbar: boolean;
readonly showChangedFilesToggle: boolean;
Expand Down Expand Up @@ -126,8 +130,14 @@ function ReviewHeader(
id: "sections",
inline: true,
items: [
sectionAction(props.sectionMenu.workingTree, "Working tree"),
sectionAction(props.sectionMenu.branchChanges, "Branch changes"),
sectionAction(
props.sectionMenu.workingTree,
props.vcsTerminology.workingTreeNounTitle,
),
sectionAction(
props.sectionMenu.branchChanges,
`${props.vcsTerminology.refNounTitle} changes`,
),
sectionAction(props.sectionMenu.latestTurn, "Latest turn"),
],
},
Expand Down Expand Up @@ -461,6 +471,13 @@ export function ReviewSheet(props: ReviewSheetProps) {
const { draftMessage } = useThreadDraftForThread({ environmentId, threadId });
const reviewCache = useReviewCacheForThread({ environmentId, threadId });
const { selectedThreadCwd } = useSelectedThreadWorktree();
const gitStatusQuery = useEnvironmentQuery(
selectedThreadCwd === null
? null
: vcsEnvironment.status({ environmentId, input: { cwd: selectedThreadCwd } }),
);
const vcsKind = gitStatusQuery.data?.vcs?.kind ?? null;
const vcsTerminology = getVcsTerminology(vcsKind);
// With a solid (non-overlay) header the content lays out below the header
// natively, so no manual top inset is needed. (Android renders its own
// in-flow AndroidScreenHeader, so it needs no inset either.)
Expand All @@ -482,6 +499,7 @@ export function ReviewSheet(props: ReviewSheetProps) {
environmentId,
threadId,
reviewCache,
vcsKind,
});
useReviewDiffPrewarming({
threadKey: reviewCache.threadKey,
Expand Down Expand Up @@ -724,6 +742,7 @@ export function ReviewSheet(props: ReviewSheetProps) {
subtitle={headerSubtitle}
androidSubtitle={androidHeaderSubtitle}
iconColor={headerIcon}
vcsTerminology={vcsTerminology}
selectedThreadCwd={selectedThreadCwd}
sectionMenu={sectionMenu}
selectedSection={selectedSection}
Expand Down
24 changes: 24 additions & 0 deletions apps/mobile/src/features/review/reviewModel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,30 @@ describe("buildReviewSectionItems", () => {
]);
expect(getDefaultReviewSectionId(items)).toBe("git:working-tree");
});

it("names a missing base with the project's ref noun", () => {
const [section] = buildReviewSectionItems({
checkpoints: [],
gitSections: [
{
id: "branch-range",
kind: "branch-range",
title: "Bookmark changes",
baseRef: null,
headRef: null,
diff: "",
diffHash: "h",
truncated: false,
},
],
turnDiffById: {},
loadingTurnIds: {},
loadingGitSections: false,
vcsKind: "jj",
});

expect(section?.subtitle).toBe("Base bookmark unavailable");
});
});

describe("buildReviewParsedDiff", () => {
Expand Down
29 changes: 20 additions & 9 deletions apps/mobile/src/features/review/reviewModel.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles";
import type { ChangeTypes, FileDiffMetadata } from "@pierre/diffs/types";
import type { OrchestrationCheckpointSummary, ReviewDiffPreviewSource } from "@t3tools/contracts";
import type {
OrchestrationCheckpointSummary,
ReviewDiffPreviewSource,
VcsDriverKind,
} from "@t3tools/contracts";
import { getVcsTerminology, type VcsTerminology } from "@t3tools/shared/vcs";
import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath";
import * as Arr from "effect/Array";
import { pipe } from "effect/Function";
Expand All @@ -9,8 +14,6 @@ import * as Order from "effect/Order";
export type ReviewSectionKind = "turn" | "working-tree" | "branch-range";

const DIRTY_WORKTREE_SECTION_ID = "git:working-tree";
const DIRTY_WORKTREE_TITLE = "Dirty worktree";
const DIRTY_WORKTREE_SUBTITLE = "Tracked, staged, and untracked worktree changes";

export interface ReviewSectionItem {
readonly id: string;
Expand Down Expand Up @@ -121,14 +124,18 @@ const readyCheckpointOrder = Order.make<OrchestrationCheckpointSummary>(
compareCheckpointTurnCountDescending,
);

function gitSubtitle(section: ReviewDiffPreviewSource): string | null {
function workingTreeSubtitle(terminology: VcsTerminology): string {
return `Tracked, staged, and untracked ${terminology.workspaceNoun} changes`;
}
Comment on lines +127 to +129

function gitSubtitle(section: ReviewDiffPreviewSource, terminology: VcsTerminology): string | null {
if (section.kind === "working-tree") {
return DIRTY_WORKTREE_SUBTITLE;
return workingTreeSubtitle(terminology);
}
if (section.baseRef) {
return `${section.baseRef} ... ${section.headRef ?? "HEAD"}`;
}
return "Base branch unavailable";
return `Base ${terminology.refNoun} unavailable`;
}

function stripTrailingNewline(value: string): string {
Expand Down Expand Up @@ -416,7 +423,9 @@ export function buildReviewSectionItems(input: {
readonly turnDiffById: Readonly<Record<string, string | undefined>>;
readonly loadingTurnIds: Readonly<Record<string, boolean | undefined>>;
readonly loadingGitSections: boolean;
readonly vcsKind?: VcsDriverKind | null;
}): ReadonlyArray<ReviewSectionItem> {
const terminology = getVcsTerminology(input.vcsKind);
const turnItems = getReadyReviewCheckpoints(input.checkpoints).map<ReviewSectionItem>(
(checkpoint) => {
const id = getReviewSectionIdForCheckpoint(checkpoint);
Expand All @@ -435,7 +444,7 @@ export function buildReviewSectionItems(input: {
id: `git:${section.kind}`,
kind: section.kind,
title: section.title,
subtitle: gitSubtitle(section),
subtitle: gitSubtitle(section, terminology),
diff: section.diff,
source: section,
...(section.files ? { files: section.files } : {}),
Expand All @@ -449,8 +458,10 @@ export function buildReviewSectionItems(input: {
{
id: DIRTY_WORKTREE_SECTION_ID,
kind: "working-tree",
title: DIRTY_WORKTREE_TITLE,
subtitle: DIRTY_WORKTREE_SUBTITLE,
// Mirrors the title each driver sends for this section, so the
// placeholder does not flicker into a different name on arrival.
title: input.vcsKind === "jj" ? "Working copy" : "Dirty worktree",
subtitle: workingTreeSubtitle(terminology),
diff: null,
isLoading: true,
} satisfies ReviewSectionItem,
Expand Down
11 changes: 10 additions & 1 deletion apps/mobile/src/features/review/useReviewSections.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { useCallback, useEffect, useMemo } from "react";
import * as DateTime from "effect/DateTime";

import type { EnvironmentId, OrchestrationCheckpointSummary, ThreadId } from "@t3tools/contracts";
import type {
EnvironmentId,
OrchestrationCheckpointSummary,
ThreadId,
VcsDriverKind,
} from "@t3tools/contracts";

import { useCheckpointDiff } from "../../state/queries";
import { useEnvironmentQuery } from "../../state/query";
Expand All @@ -28,8 +33,10 @@ export function useReviewSections(input: {
readonly environmentId?: EnvironmentId;
readonly threadId?: ThreadId;
readonly reviewCache: ReviewCacheForThread;
readonly vcsKind?: VcsDriverKind | null;
}) {
const { environmentId, reviewCache, threadId } = input;
const vcsKind = input.vcsKind ?? null;
const enabled = input.enabled ?? true;
const selectedThread = useSelectedThreadDetail();
const { selectedThreadCwd } = useSelectedThreadWorktree();
Expand Down Expand Up @@ -71,6 +78,7 @@ export function useReviewSections(input: {
turnDiffById: reviewCache.turnDiffById,
loadingTurnIds,
loadingGitSections: diffPreview.isPending,
vcsKind,
}),
[
diffPreview.isPending,
Expand All @@ -79,6 +87,7 @@ export function useReviewSections(input: {
readyCheckpoints,
reviewCache.gitSections,
reviewCache.turnDiffById,
vcsKind,
],
);
const selectedSection = useMemo(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ const PAGE_TITLES: Record<SettingsPage, string> = {

const PAGE_PROJECT_KEYS: Record<SettingsPage, readonly ProjectScopedServerSettingKey[]> = {
"new-threads": ["defaultThreadEnvMode", "worktreeSubmodules", "defaultRuntimeMode"],
"source-control": ["defaultAutoPull", "newWorktreesStartFromOrigin"],
"source-control": ["defaultAutoPull", "newWorktreesStartFromOrigin", "enableVcsAgentHints"],
"agent-behavior": ["responseStreamingMode", "enableAgentBrowserAccess"],
maintenance: ["continueThreadsAfterServerUpdate"],
};
Expand Down Expand Up @@ -387,6 +387,14 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
disabled={disabledFor("newWorktreesStartFromOrigin")}
onValueChange={(value) => write({ newWorktreesStartFromOrigin: value })}
/>
<FanoutSwitchRow
icon="text.bubble"
label="VCS agent hints"
subtitle="Give agents a version-control hint on each turn, including resumed threads."
value={uniform("enableVcsAgentHints")}
disabled={disabledFor("enableVcsAgentHints")}
onValueChange={(value) => write({ enableVcsAgentHints: value })}
/>
<WorktreeSettingField
label="Worktree directory"
hint="Root for new worktrees; leave empty for the T3 home directory."
Expand Down
33 changes: 22 additions & 11 deletions apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,10 @@ export function NewTaskBranchPickerRouteScreen() {
const selectingBranchNameRef = useRef<string | null>(null);
const allowSelectionNavigationRef = useRef(false);
const mountedRef = useRef(true);
const screenTitle = flow.workspaceMode === "worktree" ? "Base branch" : "Branch";
const screenTitle =
flow.workspaceMode === "worktree"
? `Base ${flow.vcsTerminology.refNoun}`
: flow.vcsTerminology.refNounTitle;
const usesNativeMailSearchToolbar = Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED;
const selectedBranchName =
flow.selectedBranchName ??
Expand Down Expand Up @@ -327,8 +330,10 @@ export function NewTaskBranchPickerRouteScreen() {
if (mountedRef.current && navigation.isFocused() && !isAtomCommandInterrupted(result)) {
const error = squashAtomCommandFailure(result);
Alert.alert(
"Could not switch branch",
error instanceof Error ? error.message : "The branch could not be checked out.",
`Could not switch ${flow.vcsTerminology.refNoun}`,
error instanceof Error
? error.message
: `The ${flow.vcsTerminology.refNoun} could not be checked out.`,
);
}
return;
Expand Down Expand Up @@ -357,6 +362,7 @@ export function NewTaskBranchPickerRouteScreen() {
flow.selectedProject,
flow.setBranchQuery,
flow.workspaceMode,
flow.vcsTerminology,
navigation,
switchRef,
],
Expand All @@ -365,7 +371,11 @@ export function NewTaskBranchPickerRouteScreen() {
const renderBranch = useCallback(
({ item, index }: { readonly item: VcsRef; readonly index: number }) => (
<BranchSelectionRow
badge={branchBadgeLabel({ branch: item, project: flow.selectedProject })}
badge={branchBadgeLabel({
branch: item,
project: flow.selectedProject,
terminology: flow.vcsTerminology,
})}
branch={item}
disabled={switchingBranchName !== null}
isFirst={index === 0}
Expand All @@ -377,6 +387,7 @@ export function NewTaskBranchPickerRouteScreen() {
[
flow.filteredBranches.length,
flow.selectedProject,
flow.vcsTerminology,
selectBranch,
selectedBranchName,
switchingBranchName,
Expand Down Expand Up @@ -420,12 +431,12 @@ export function NewTaskBranchPickerRouteScreen() {
{flow.branchesLoading ? <ActivityIndicator /> : null}
<Text className="text-center text-sm text-foreground-muted">
{flow.branchesLoading
? "Loading branches…"
? `Loading ${flow.vcsTerminology.refNounPlural}…`
: flow.branchesError
? flow.branchesError
: flow.branchQuery
? "No matching branches"
: "No branches available"}
? `No matching ${flow.vcsTerminology.refNounPlural}`
: `No ${flow.vcsTerminology.refNounPlural} available`}
</Text>
{!flow.branchesLoading && flow.branchesError ? (
<Pressable
Expand Down Expand Up @@ -480,13 +491,13 @@ export function NewTaskBranchPickerRouteScreen() {
<TextInput
autoCapitalize="none"
autoCorrect={false}
accessibilityLabel="Find a branch"
accessibilityLabel={`Find a ${flow.vcsTerminology.refNoun}`}
className="h-12 rounded-full border border-input-border bg-input px-4 font-sans text-base text-foreground"
selectionColorClassName="accent-focus/32"
cursorColorClassName="accent-focus"
selectionHandleColorClassName="accent-focus"
onChangeText={flow.setBranchQuery}
placeholder="Find a branch"
placeholder={`Find a ${flow.vcsTerminology.refNoun}`}
placeholderTextColorClassName="accent-placeholder"
value={flow.branchQuery}
/>
Expand All @@ -506,7 +517,7 @@ export function NewTaskBranchPickerRouteScreen() {
? () => [
createNativeMailSearchToolbarItem({
onSearchTextChange: flow.setBranchQuery,
placeholder: "Find a branch",
placeholder: `Find a ${flow.vcsTerminology.refNoun}`,
searchTextChangeId: "new-task-branch-search-text",
showsSearchDismissButton: true,
}),
Expand All @@ -519,7 +530,7 @@ export function NewTaskBranchPickerRouteScreen() {
autoCapitalize: "none",
hideNavigationBar: false,
obscureBackground: false,
placeholder: "Find a branch",
placeholder: `Find a ${flow.vcsTerminology.refNoun}`,
onChangeText: (event) => {
flow.setBranchQuery(event.nativeEvent.text);
},
Expand Down
Loading
Loading