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
75 changes: 75 additions & 0 deletions apps/web/src/components/GitActionsControl.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { assert, describe, it } from "vite-plus/test";
import {
buildGitActionProgressStages,
buildMenuItems,
canCreatePrFromPushedWork,
requiresDefaultBranchConfirmation,
resolveAutoFeatureBranchName,
resolveDefaultBranchActionDialogCopy,
Expand Down Expand Up @@ -874,6 +875,80 @@ describe("when: ref has no upstream configured", () => {
});
});

describe("canCreatePrFromPushedWork", () => {
it("arms once the ref is clean, in sync, and has no open PR", () => {
assert.isTrue(canCreatePrFromPushedWork(status({ aheadOfDefaultCount: 2 }), false));
});

it("stays inert while an action is running or status is unknown", () => {
assert.isFalse(canCreatePrFromPushedWork(status({ aheadOfDefaultCount: 2 }), true));
assert.isFalse(canCreatePrFromPushedWork(null, false));
});

it("stays inert outside a repo with a primary remote", () => {
assert.isFalse(
canCreatePrFromPushedWork(status({ isRepo: false, aheadOfDefaultCount: 2 }), false),
);
assert.isFalse(
canCreatePrFromPushedWork(status({ hasPrimaryRemote: false, aheadOfDefaultCount: 2 }), false),
);
});

it("stays inert on a detached HEAD", () => {
assert.isFalse(
canCreatePrFromPushedWork(status({ refName: null, aheadOfDefaultCount: 2 }), false),
);
});

it("stays inert while any work is unpushed", () => {
assert.isFalse(
canCreatePrFromPushedWork(
status({ hasWorkingTreeChanges: true, aheadOfDefaultCount: 2 }),
false,
),
);
assert.isFalse(
canCreatePrFromPushedWork(status({ hasUpstream: false, aheadOfDefaultCount: 2 }), false),
);
assert.isFalse(
canCreatePrFromPushedWork(status({ aheadCount: 1, aheadOfDefaultCount: 2 }), false),
);
assert.isFalse(
canCreatePrFromPushedWork(status({ behindCount: 1, aheadOfDefaultCount: 2 }), false),
);
});

it("stays inert when a PR is already open", () => {
assert.isFalse(
canCreatePrFromPushedWork(
status({
aheadOfDefaultCount: 2,
pr: {
number: 12,
title: "Open PR",
url: "https://example.com/pr/12",
baseRef: "main",
headRef: "feature/test",
state: "open",
},
}),
false,
),
);
});

it("stays inert when the ref has nothing to propose against the default ref", () => {
assert.isFalse(canCreatePrFromPushedWork(status({ aheadOfDefaultCount: 0 }), false));
});

it("falls back to the default-ref check when the count is missing", () => {
assert.isTrue(canCreatePrFromPushedWork(status(), false));
assert.isFalse(
canCreatePrFromPushedWork(status({ isDefaultRef: true, refName: "main" }), false),
);
});
});

describe("requiresDefaultBranchConfirmation", () => {
it("requires confirmation for push actions on default ref", () => {
assert.isFalse(requiresDefaultBranchConfirmation("commit", true));
Expand Down
27 changes: 27 additions & 0 deletions apps/web/src/components/GitActionsControl.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,33 @@ export function buildMenuItems(
];
}

/**
* Gates the `git.createPullRequest` keybinding via the `gitCanCreatePr` when-clause
* variable. Deliberately narrower than the "Create PR" menu item: that item also
* accepts unpushed work and pushes first, whereas the shortcut only arms once the
* ref has nothing left to send, so a stray keypress can never publish commits the
* user has not pushed yet.
*
* `aheadOfDefaultCount` is optional on the wire. Servers old enough to omit it fall
* back to "not on the default ref", which keeps the shortcut alive there and lets the
* server reject the action if the ref turns out to have nothing to propose.
*/
export function canCreatePrFromPushedWork(
gitStatus: VcsStatusResult | null,
isBusy: boolean,
): boolean {
if (isBusy || !gitStatus) return false;
if (!gitStatus.isRepo || !gitStatus.hasPrimaryRemote) return false;
if (gitStatus.refName === null) return false;
if (gitStatus.hasWorkingTreeChanges) return false;
if (!gitStatus.hasUpstream) return false;
if (gitStatus.aheadCount > 0 || gitStatus.behindCount > 0) return false;
if (gitStatus.pr?.state === "open") return false;
return gitStatus.aheadOfDefaultCount === undefined
? !gitStatus.isDefaultRef
: gitStatus.aheadOfDefaultCount > 0;
}

export function resolveQuickAction(
gitStatus: VcsStatusResult | null,
isBusy: boolean,
Expand Down
35 changes: 34 additions & 1 deletion apps/web/src/components/GitActionsControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { cn } from "~/lib/utils";
import {
buildGitActionProgressStages,
buildMenuItems,
canCreatePrFromPushedWork,
type GitActionIconName,
type GitActionMenuItem,
type GitQuickAction,
Expand Down Expand Up @@ -79,7 +80,7 @@ import {
} from "~/lib/sourceControlActions";
import { useThread } from "~/state/entities";
import { useEnvironmentQuery } from "~/state/query";
import { serverEnvironment } from "~/state/server";
import { primaryServerKeybindingsAtom, serverEnvironment } from "~/state/server";
import { sourceControlEnvironment } from "~/state/sourceControl";
import { threadEnvironment } from "~/state/threads";
import { useAtomCommand } from "~/state/use-atom-command";
Expand All @@ -90,6 +91,9 @@ import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";
import { readLocalApi } from "~/localApi";
import { getSourceControlPresentation } from "~/sourceControlPresentation";
import { openPullRequestLink } from "~/lib/openPullRequestLink";
import { isCommandPaletteOpen } from "~/commandPaletteBus";
import { resolveShortcutCommand } from "~/keybindings";
import { isTerminalFocused } from "~/lib/terminalFocus";

interface GitActionsControlProps {
gitCwd: string | null;
Expand Down Expand Up @@ -977,6 +981,7 @@ export default function GitActionsControl({
"thread branch metadata update",
);
const activeEnvironmentId = activeThreadRef?.environmentId ?? null;
const keybindings = useAtomValue(primaryServerKeybindingsAtom);
const serverConfig = useAtomValue(serverEnvironment.configValueAtom(activeEnvironmentId));
const openInPreferredEditor = useOpenInPreferredEditor(
activeEnvironmentId,
Expand Down Expand Up @@ -1154,6 +1159,10 @@ export default function GitActionsControl({
resolveQuickAction(gitStatusForActions, isGitActionRunning, isDefaultRef, hasPrimaryRemote),
[gitStatusForActions, hasPrimaryRemote, isDefaultRef, isGitActionRunning],
);
const canCreatePrFromShortcut = useMemo(
() => canCreatePrFromPushedWork(gitStatusForActions, isGitActionRunning),
[gitStatusForActions, isGitActionRunning],
);
const quickActionDisabledReason = quickAction.disabled
? (quickAction.hint ?? "This action is currently unavailable.")
: null;
Expand Down Expand Up @@ -1606,6 +1615,30 @@ export default function GitActionsControl({
setIsCommitDialogOpen(true);
};

// This control owns the git status for the active thread, so it also owns the
// `git.createPullRequest` shortcut rather than routing it through the global
// handler in `routes/_chat.tsx`, which would need a second status subscription
// to evaluate `gitCanCreatePr`.
const handleCreatePrShortcut = useEffectEvent((event: KeyboardEvent) => {
if (event.defaultPrevented || isCommandPaletteOpen()) return;
const command = resolveShortcutCommand(event, keybindings, {
context: {
terminalFocus: isTerminalFocused(),
gitCanCreatePr: canCreatePrFromShortcut,
},
});
if (command !== "git.createPullRequest") return;
event.preventDefault();
event.stopPropagation();
void runGitActionWithToast({ action: "create_pr" });
});

useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => handleCreatePrShortcut(event);
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);

const runDialogAction = () => {
if (!isCommitDialogOpen) return;
const commitMessage = dialogCommitMessage.trim();
Expand Down
57 changes: 57 additions & 0 deletions apps/web/src/keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,18 @@ const DEFAULT_BINDINGS = compile([
{ shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" },
{ shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newLocal" },
{ shortcut: modShortcut("o"), command: "editor.openFavorite" },
{
shortcut: modShortcut("p", { shiftKey: true }),
command: "git.createPullRequest",
whenAst: whenAnd(whenNot(whenIdentifier("terminalFocus")), whenIdentifier("gitCanCreatePr")),
},
{ shortcut: modShortcut("[", { shiftKey: true }), command: "thread.previous" },
{ shortcut: modShortcut("]", { shiftKey: true }), command: "thread.next" },
{
shortcut: modShortcut("a", { shiftKey: true }),
command: "thread.archive",
whenAst: whenNot(whenIdentifier("terminalFocus")),
},
{ shortcut: modShortcut("1"), command: "thread.jump.1" },
{ shortcut: modShortcut("2"), command: "thread.jump.2" },
{ shortcut: modShortcut("3"), command: "thread.jump.3" },
Expand Down Expand Up @@ -515,6 +525,53 @@ describe("chat/editor shortcuts", () => {
);
});

it("matches git.createPullRequest only when the ref has nothing left to send", () => {
assert.strictEqual(
resolveShortcutCommand(event({ key: "p", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, {
platform: "MacIntel",
context: { gitCanCreatePr: true },
}),
"git.createPullRequest",
);
assert.isNull(
resolveShortcutCommand(event({ key: "p", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, {
platform: "MacIntel",
context: { gitCanCreatePr: false },
}),
);
assert.isNull(
resolveShortcutCommand(event({ key: "p", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, {
platform: "MacIntel",
context: { gitCanCreatePr: true, terminalFocus: true },
}),
);
});

it("keeps filePicker.toggle on the unshifted mod+p", () => {
assert.strictEqual(
resolveShortcutCommand(event({ key: "p", metaKey: true }), DEFAULT_BINDINGS, {
platform: "MacIntel",
context: { gitCanCreatePr: true },
}),
"filePicker.toggle",
);
});

it("matches thread.archive outside terminal focus", () => {
assert.strictEqual(
resolveShortcutCommand(event({ key: "a", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, {
platform: "MacIntel",
}),
"thread.archive",
);
assert.isNull(
resolveShortcutCommand(event({ key: "a", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, {
platform: "MacIntel",
context: { terminalFocus: true },
}),
);
});

it("matches commandPalette.toggle shortcut outside terminal focus", () => {
assert.strictEqual(
resolveShortcutCommand(event({ key: "k", metaKey: true }), DEFAULT_BINDINGS, {
Expand Down
34 changes: 33 additions & 1 deletion apps/web/src/routes/_chat.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { Outlet, createFileRoute, redirect } from "@tanstack/react-router";
import { useAtomValue } from "@effect/atom-react";
import { useEffect, useMemo } from "react";
import type { ScopedThreadRef } from "@t3tools/contracts";
import {
isAtomCommandInterrupted,
squashAtomCommandFailure,
} from "@t3tools/client-runtime/state/runtime";
import { useCallback, useEffect, useMemo } from "react";

import { isCommandPaletteOpen } from "../commandPaletteBus";
import { useClientSettings, useSidebarV2Enabled } from "../hooks/useSettings";
Expand All @@ -18,6 +23,7 @@ import { resolveShortcutCommand } from "../keybindings";
import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
import { isPreviewSupportedInRuntime } from "../previewStateStore";
import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore";
import { useThreadActions } from "../hooks/useThreadActions";
import { useThreadSelectionStore } from "../threadSelectionStore";
import { stackedThreadToast, toastManager } from "~/components/ui/toast";
import { primaryServerKeybindingsAtom } from "~/state/server";
Expand All @@ -27,6 +33,7 @@ function ChatRouteGlobalShortcuts() {
const selectedThreadKeysSize = useThreadSelectionStore((state) => state.selectedThreadKeys.size);
const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } =
useHandleNewThread();
const { archiveThread } = useThreadActions();
const keybindings = useAtomValue(primaryServerKeybindingsAtom);
const sidebarV2Enabled = useSidebarV2Enabled();
const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
Expand Down Expand Up @@ -55,6 +62,22 @@ function ChatRouteGlobalShortcuts() {
? selectActiveRightPanel(state.byThreadKey, routeThreadRef) === "preview"
: false,
);
const archiveActiveThread = useCallback(
async (threadRef: ScopedThreadRef) => {
const result = await archiveThread(threadRef);
if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return;
const error = squashAtomCommandFailure(result);
toastManager.add(
stackedThreadToast({
type: "error",
title: "Failed to archive thread",
description: error instanceof Error ? error.message : "An error occurred.",
}),
);
},
[archiveThread],
);

useEffect(() => {
const onWindowKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented) return;
Expand All @@ -77,6 +100,14 @@ function ChatRouteGlobalShortcuts() {
return;
}

if (command === "thread.archive") {
event.preventDefault();
event.stopPropagation();
if (!routeThreadRef) return;
void archiveActiveThread(routeThreadRef);
return;
}

if (command === "chat.newLocal") {
event.preventDefault();
event.stopPropagation();
Expand Down Expand Up @@ -159,6 +190,7 @@ function ChatRouteGlobalShortcuts() {
}, [
activeDraftThread,
activeThread,
archiveActiveThread,
clearSelection,
handleNewThread,
keybindings,
Expand Down
20 changes: 17 additions & 3 deletions docs/user/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ agent responses across connected environments. Message matches show one labeled
keeping the thread's project, branch, and machine context visible. Message search begins after two
characters and uses SQLite's ASCII case-insensitive matching.

`git.createPullRequest` opens a pull request for the thread's ref and defaults to `mod+shift+p`.
It is deliberately narrow: the shortcut only works when the ref has nothing left to send, which
means no uncommitted changes, no local commits waiting on the upstream, nothing to pull, and no
pull request open already. While work is still uncommitted or unpushed, the shortcut does nothing;
use the source control button in the thread header, which offers to commit and push first.

`thread.archive` archives the thread you are looking at and defaults to `mod+shift+a`. Archiving
the open thread moves you to a new draft in the same project. A thread with a turn in flight is not
archived, and you get an error toast instead. Restore threads from **Settings** → **Archived**.

The full command list and the current defaults are shown in **Settings** → **Keybindings**, which
always matches the build you are running. Use that rather than a copied list.

Expand All @@ -59,9 +69,13 @@ project, `chat.new` opens a project chooser first.
## `when` Conditions

A `when` expression is evaluated against context keys describing the current UI state. The keys
the app supplies today are `terminalFocus`, `terminalOpen`, `previewFocus`, `previewOpen`, and
`modelPickerOpen`. The set is open and grows over time, so treat that as the current list rather
than a fixed one. Any key the running app does not supply evaluates to `false`.
the app supplies today are `terminalFocus`, `terminalOpen`, `previewFocus`, `previewOpen`,
`modelPickerOpen`, and `gitCanCreatePr`. The set is open and grows over time, so treat that as the
current list rather than a fixed one. Any key the running app does not supply evaluates to `false`.

`gitCanCreatePr` is true only while the thread's ref has nothing left to send and no pull request
open. It is supplied to the source control shortcuts, so a rule that uses it elsewhere reads as
`false`.

Operators: `!` (not), `&&` (and), `||` (or), and parentheses.

Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export type ModelPickerJumpKeybindingCommand =
export const THREAD_KEYBINDING_COMMANDS = [
"thread.previous",
"thread.next",
"thread.archive",
...THREAD_JUMP_KEYBINDING_COMMANDS,
] as const;
export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number];
Expand Down Expand Up @@ -69,6 +70,7 @@ const STATIC_KEYBINDING_COMMANDS = [
"chat.new",
"chat.newLocal",
"editor.openFavorite",
"git.createPullRequest",
...MODEL_PICKER_KEYBINDING_COMMANDS,
...THREAD_KEYBINDING_COMMANDS,
] as const;
Expand Down
Loading
Loading