Skip to content
Open
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
34 changes: 34 additions & 0 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
withMessageContext,
deriveAvailableRevertTurnCounts,
deriveWorkspaceMutationId,
canSelectNewWorktree,
} from "./ChatView.logic";
import { recallCheckoutIsRepo, rememberCheckoutIsRepo } from "./ChatView.logic";
import { ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";
Expand Down Expand Up @@ -626,6 +627,39 @@ describe("checkout Git memory", () => {
});
});

describe("canSelectNewWorktree", () => {
const localDraft = {
envLocked: false,
isMultiModel: false,
isLocalDraftThread: true,
canOverrideServerThreadEnvMode: false,
};

it("keeps a non-Git draft out of worktree mode before its first status arrives", () => {
expect(canSelectNewWorktree({ ...localDraft, isGitRepo: undefined })).toBe(false);
expect(canSelectNewWorktree({ ...localDraft, isGitRepo: false })).toBe(false);
});

it("allows a draft once live or remembered status confirms a Git repository", () => {
expect(canSelectNewWorktree({ ...localDraft, isGitRepo: true })).toBe(true);
});

it("keeps locked and multi-model drafts out of worktree mode", () => {
expect(canSelectNewWorktree({ ...localDraft, isGitRepo: true, envLocked: true })).toBe(false);
expect(canSelectNewWorktree({ ...localDraft, isGitRepo: true, isMultiModel: true })).toBe(
false,
);
});

it("allows only overridable server threads", () => {
const serverThread = { ...localDraft, isGitRepo: true, isLocalDraftThread: false };
expect(canSelectNewWorktree(serverThread)).toBe(false);
expect(canSelectNewWorktree({ ...serverThread, canOverrideServerThreadEnvMode: true })).toBe(
true,
);
});
});

describe("worktree setup visibility", () => {
const stage = (
id: "fetch" | "checkout" | "submodules" | "setup-script" | "agent",
Expand Down
15 changes: 15 additions & 0 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -868,6 +868,21 @@ export function recallCheckoutIsRepo(
: sessionCheckoutIsRepo.get(checkoutIsRepoKey(environmentId, cwd));
}

export function canSelectNewWorktree(input: {
readonly isGitRepo: boolean | undefined;
readonly envLocked: boolean;
readonly isMultiModel: boolean;
readonly isLocalDraftThread: boolean;
readonly canOverrideServerThreadEnvMode: boolean;
}): boolean {
return (
input.isGitRepo === true &&
!input.envLocked &&
!input.isMultiModel &&
(input.isLocalDraftThread || input.canOverrideServerThreadEnvMode)
);
}

function threadHasStarted(thread: Thread | null | undefined): boolean {
return Boolean(thread && (thread.latestRun !== null || thread.itemCount > 0 || thread.runtime));
}
Expand Down
22 changes: 20 additions & 2 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,11 @@ import { THREAD_FIND_BAR_RESERVED_HEIGHT } from "./chat/ThreadFindBar";
import { usageLimitRecoveryBannerItem } from "./chat/UsageLimitRecoveryBanner";
import { resolveDraftHeroState } from "./ChatView.logic";
import { useQueuedRunEdit } from "./chat/useQueuedRunEdit";
import { recallCheckoutIsRepo, rememberCheckoutIsRepo } from "./ChatView.logic";
import {
canSelectNewWorktree,
recallCheckoutIsRepo,
rememberCheckoutIsRepo,
} from "./ChatView.logic";
import { useLogicalProjectEnvironments } from "../hooks/useLogicalProjectEnvironments";
import { useStableCallback } from "../hooks/useStableCallback";
import { useProjectScriptEditing } from "./chat/useProjectScriptEditing";
Expand Down Expand Up @@ -1688,7 +1692,8 @@ export default function ChatView(props: ChatViewProps) {
rememberCheckoutIsRepo(environmentId, gitStatusCwd, liveIsGitRepo);
}
}, [environmentId, gitStatusCwd, liveIsGitRepo]);
const isGitRepo = liveIsGitRepo ?? recallCheckoutIsRepo(environmentId, gitStatusCwd) ?? true;
const knownIsGitRepo = liveIsGitRepo ?? recallCheckoutIsRepo(environmentId, gitStatusCwd);
const isGitRepo = knownIsGitRepo ?? true;
// Provider-native subagents cannot take messages; approvals and questions go to the top-level parent.
const showProviderSubagentBar = isProviderSubagent;
const composerMounted = !showProviderSubagentBar;
Expand Down Expand Up @@ -2676,6 +2681,19 @@ export default function ChatView(props: ChatViewProps) {
const next = logicalProjectEnvironments[(index + 1) % logicalProjectEnvironments.length];
if (next) onEnvironmentChangeStable(next.environmentId);
},
"composer.newWorktree": ({ event }) => {
if (
!canSelectNewWorktree({
isGitRepo: knownIsGitRepo,
envLocked,
isMultiModel: multipleModelSelections !== null,
isLocalDraftThread,
canOverrideServerThreadEnvMode,
})
)
return false;
if (!event.repeat) onEnvModeChangeStable("worktree");
},
"thread.steerQueuedMessage": ({ event }) =>
routeKind !== "draft" && queuedRunsControlRef.current?.steerNext(event.repeat) === true,
"thread.editQueuedMessage": ({ event }) =>
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/components/settings/KeybindingsSettings.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@ import { assert, describe, expect, it } from "vite-plus/test";
import type { ResolvedKeybindingsConfig } from "@t3tools/contracts";
import {
compileResolvedKeybindingsConfig,
DEFAULT_RESOLVED_KEYBINDINGS,
parseKeybindingShortcut,
} from "@t3tools/shared/keybindings";

import { resolveShortcutCommand } from "../../keybindings";

import {
buildKeybindingCommandOptions,
buildKeybindingRows,
commandLabel,
keybindingConflictLabels,
keybindingFromKeyboardEvent,
parseWhenExpressionDraft,
Expand All @@ -17,6 +20,13 @@ import {
} from "./KeybindingsSettings.logic";

describe("KeybindingsSettings.logic", () => {
it("offers new worktree as a bindable composer command without a default shortcut", () => {
const command = buildKeybindingCommandOptions([]).find((id) => id === "composer.newWorktree");
expect(command).toBe("composer.newWorktree");
expect(commandLabel(command!)).toBe("Composer: New Worktree");
expect(DEFAULT_RESOLVED_KEYBINDINGS.some((binding) => binding.command === command)).toBe(false);
});

it("keeps sibling row identities when a saved binding moves to the end", () => {
const bindings = compileResolvedKeybindingsConfig([
{ command: "chat.new", key: "mod+n" },
Expand Down
2 changes: 2 additions & 0 deletions docs/user/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ Use `mod+shift+e` for effort, `mod+shift+a` for permissions, `mod+shift+x` for t
workspace, and `mod+shift+g` for the Git branch. The workspace menu includes the
current checkout, a new worktree, and the previous worktree when available.
Use `mod+shift+l` to reuse the previous worktree directly.
To select a new worktree directly, bind **Composer: New Worktree** in Keybindings.
It has no default shortcut and uses the same rules as the workspace menu.

To change effort one step without opening the picker, bind
**Composer: Increase Effort** and **Composer: Decrease Effort** in Keybindings.
Expand Down
13 changes: 12 additions & 1 deletion packages/contracts/src/keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { assert, it } from "@effect/vitest";
import * as Schema from "effect/Schema";
import * as Effect from "effect/Effect";

import { ResolvedKeybindingsConfig } from "./keybindings.ts";
import { KeybindingsConfig, ResolvedKeybindingsConfig } from "./keybindings.ts";

const decode = <S extends Schema.Top>(
schema: S,
Expand All @@ -23,6 +23,17 @@ const shortcut = {
modKey: true,
};

it.effect("accepts a custom shortcut for selecting a new worktree", () =>
Effect.gen(function* () {
const config = [
{ command: "composer.newWorktree", key: "mod+alt+w", when: "!terminalFocus" },
] as const;
assert.deepEqual(yield* decode(KeybindingsConfig, config), config);
const resolved = [{ command: "composer.newWorktree", shortcut }] as const;
assert.deepEqual(yield* decode(ResolvedKeybindingsConfig, resolved), resolved);
}),
);

it.effect("drops resolved rules with commands this build does not know", () =>
Effect.gen(function* () {
const parsed = yield* decode(ResolvedKeybindingsConfig, [
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export const STATIC_KEYBINDING_COMMANDS = [
"composer.decreaseEffort",
"composer.mode",
"composer.workspace",
"composer.newWorktree",
"composer.previousWorktree",
"composer.branch",
"chat.new",
Expand Down
Loading