Files
kandev/apps/web/components/session-commands.tsx
T
José Almeida 80b5516199 fix(web): improve command palette alias matching (#1718)
* fix(web): improve command palette alias matching

* docs(web): explain command priority tie-breaker

* fix(web): preserve command palette selection
2026-07-16 00:22:31 +01:00

330 lines
9.4 KiB
TypeScript

"use client";
import { useCallback, useMemo, useState } from "react";
import {
IconPlayerStop,
IconGitCommit,
IconArrowUp,
IconArrowDown,
IconGitPullRequest,
IconGitBranch,
IconGitMerge,
IconBrowser,
IconTerminal2,
IconFileText,
IconFileDiff,
IconFilePlus,
IconMessagePlus,
IconSubtask,
} from "@tabler/icons-react";
import { useRegisterCommands } from "@/hooks/use-register-commands";
import { useGitOperations } from "@/hooks/use-git-operations";
import { useGitWithFeedback } from "@/hooks/use-git-with-feedback";
import { usePanelActions } from "@/hooks/use-panel-actions";
import { useVcsDialogs } from "@/components/vcs/vcs-dialogs";
import { useAppStore } from "@/components/state-provider";
import { getWebSocketClient } from "@/lib/ws/connection";
import { createFile } from "@/lib/ws/workspace-files";
import { useDockviewStore } from "@/lib/state/dockview-store";
import { NewSessionDialog } from "@/components/task/new-session-dialog";
import { NewSubtaskDialog } from "@/components/task/new-subtask-dialog";
import type { CommandItem } from "@/lib/commands/types";
type SessionCommandsProps = {
sessionId: string | null;
baseBranch?: string;
isAgentRunning?: boolean;
hasWorktree?: boolean;
isPassthrough?: boolean;
};
type GitRunFn = (
op: () => Promise<{ success: boolean; output: string; error?: string }>,
name: string,
) => Promise<void>;
type GitOps = ReturnType<typeof useGitOperations>;
type PanelActions = ReturnType<typeof usePanelActions>;
function buildSessionCommands(
isAgentRunning: boolean | undefined,
cancelTurn: () => void,
): CommandItem[] {
const items: CommandItem[] = [];
if (isAgentRunning)
items.push({
id: "session-cancel",
label: "Cancel Turn",
group: "Agent",
icon: <IconPlayerStop className="size-3.5" />,
keywords: ["cancel", "stop", "turn", "cancel agent", "stop agent", "interrupt agent"],
action: cancelTurn,
});
return items;
}
function buildGitCommands(
git: GitOps,
baseBranch: string | undefined,
openCommitDialog: () => void,
openPRDialog: () => void,
runGitWithFeedback: GitRunFn,
): CommandItem[] {
return [
{
id: "git-commit",
label: "Commit Changes",
group: "Git",
icon: <IconGitCommit className="size-3.5" />,
keywords: ["commit", "git", "save changes", "git commit"],
action: openCommitDialog,
},
{
id: "git-push",
label: "Push",
group: "Git",
icon: <IconArrowUp className="size-3.5" />,
keywords: ["push", "git", "push changes", "push to remote", "upload changes"],
action: () => runGitWithFeedback(() => git.push(), "Push"),
},
{
id: "git-pull",
label: "Pull",
group: "Git",
icon: <IconArrowDown className="size-3.5" />,
keywords: ["pull", "git", "pull changes", "download changes"],
action: () => runGitWithFeedback(() => git.pull(), "Pull"),
},
{
id: "git-create-pr",
label: "Create PR",
group: "Git",
icon: <IconGitPullRequest className="size-3.5" />,
keywords: ["pull request", "pr", "open pull request", "submit pull request", "git"],
action: openPRDialog,
},
{
id: "git-rebase",
label: "Rebase",
group: "Git",
icon: <IconGitBranch className="size-3.5" />,
keywords: ["rebase", "git", "branch"],
action: () => {
const t = baseBranch?.replace(/^origin\//, "") || "main";
return runGitWithFeedback(() => git.rebase(t), "Rebase");
},
},
{
id: "git-merge",
label: "Merge",
group: "Git",
icon: <IconGitMerge className="size-3.5" />,
keywords: ["merge", "git", "branch"],
action: () => {
const t = baseBranch?.replace(/^origin\//, "") || "main";
return runGitWithFeedback(() => git.merge(t), "Merge");
},
},
];
}
function buildWorkspaceCommands(sessionId: string): CommandItem[] {
return [
{
id: "workspace-create-file",
label: "Create File",
group: "Workspace",
icon: <IconFilePlus className="size-3.5" />,
keywords: ["create", "new", "file", "add"],
enterMode: "input",
inputPlaceholder: "File path relative to workspace root...",
onInputSubmit: async (path) => {
const client = getWebSocketClient();
if (!client) return;
try {
const response = await createFile(client, sessionId, path);
if (response.success) {
const name = path.split("/").pop() || path;
useDockviewStore.getState().addFileEditorPanel(path, name);
}
} catch (error) {
console.error("Failed to create file:", error);
}
},
},
];
}
function buildTaskCommands(
activeTaskId: string | null,
openNewAgent: () => void,
openSubtask: () => void,
): CommandItem[] {
if (!activeTaskId) return [];
return [
{
id: "agent-new",
label: "New Agent",
group: "Agent",
icon: <IconMessagePlus className="size-3.5" />,
keywords: ["new", "agent", "session", "start agent", "new session"],
action: openNewAgent,
},
{
id: "subtask-create",
label: "Create Subtask",
group: "Tasks",
icon: <IconSubtask className="size-3.5" />,
keywords: ["subtask", "create", "new subtask", "new sub-task", "child task"],
action: openSubtask,
},
];
}
function buildPanelCommands(
panels: PanelActions,
isPassthrough: boolean | undefined,
): CommandItem[] {
const items: CommandItem[] = [
{
id: "panel-browser",
label: "Add Browser Panel",
group: "Panels",
icon: <IconBrowser className="size-3.5" />,
keywords: ["browser", "preview", "web", "open browser preview", "web preview", "app preview"],
action: () => panels.addBrowser(),
},
{
id: "panel-terminal",
label: "Add Terminal Panel",
group: "Panels",
icon: <IconTerminal2 className="size-3.5" />,
keywords: ["terminal", "shell", "console", "new terminal", "open terminal", "command line"],
action: () => panels.addTerminal(),
},
];
if (!isPassthrough)
items.push({
id: "panel-plan",
label: "Add Plan Panel",
group: "Panels",
icon: <IconFileText className="size-3.5" />,
keywords: ["plan", "document", "task plan", "implementation plan", "plan details"],
action: () => panels.addPlan(),
});
items.push({
id: "panel-changes",
label: "Add Changes Panel",
group: "Panels",
icon: <IconFileDiff className="size-3.5" />,
keywords: [
"changes",
"diff",
"git changes",
"changed files",
"source control",
"git diff",
"review changes",
],
action: () => panels.addChanges(),
});
return items;
}
export function SessionCommands({
sessionId,
baseBranch,
isAgentRunning,
hasWorktree,
isPassthrough,
}: SessionCommandsProps) {
const git = useGitOperations(sessionId);
const panels = usePanelActions();
const { openCommitDialog, openPRDialog } = useVcsDialogs();
const gitWithFeedback = useGitWithFeedback();
const activeTaskId = useAppStore((s) => s.tasks.activeTaskId);
const activeTaskTitle = useAppStore((s) => {
const id = s.tasks.activeTaskId;
if (!id) return "";
return s.kanban.tasks.find((t: { id: string }) => t.id === id)?.title ?? "";
});
const [showNewAgentDialog, setShowNewAgentDialog] = useState(false);
const [showSubtaskDialog, setShowSubtaskDialog] = useState(false);
const openNewAgent = useCallback(() => setShowNewAgentDialog(true), []);
const openSubtask = useCallback(() => setShowSubtaskDialog(true), []);
const cancelTurn = useCallback(async () => {
if (!sessionId) return;
const client = getWebSocketClient();
if (!client) return;
try {
await client.request("agent.cancel", { session_id: sessionId }, 15000);
} catch (error) {
console.error("Failed to cancel agent turn:", error);
}
}, [sessionId]);
const runGitWithFeedback = useCallback(
async (
operation: () => Promise<{ success: boolean; output: string; error?: string }>,
operationName: string,
) => {
panels.addChanges();
await gitWithFeedback(operation, operationName);
},
[panels, gitWithFeedback],
);
const commands = useMemo<CommandItem[]>(() => {
if (!sessionId) return [];
const items = [
...buildSessionCommands(isAgentRunning, cancelTurn),
...(hasWorktree
? buildGitCommands(git, baseBranch, openCommitDialog, openPRDialog, runGitWithFeedback)
: []),
...(hasWorktree ? buildWorkspaceCommands(sessionId) : []),
...buildPanelCommands(panels, isPassthrough),
...buildTaskCommands(activeTaskId, openNewAgent, openSubtask),
];
return items.map((cmd) => ({ ...cmd, priority: 0 }));
}, [
sessionId,
activeTaskId,
git,
panels,
cancelTurn,
baseBranch,
isAgentRunning,
hasWorktree,
isPassthrough,
openCommitDialog,
openPRDialog,
runGitWithFeedback,
openNewAgent,
openSubtask,
]);
useRegisterCommands(commands);
return (
<>
{activeTaskId && (
<NewSessionDialog
open={showNewAgentDialog}
onOpenChange={setShowNewAgentDialog}
taskId={activeTaskId}
/>
)}
{activeTaskId && (
<NewSubtaskDialog
open={showSubtaskDialog}
onOpenChange={setShowSubtaskDialog}
parentTaskId={activeTaskId}
parentTaskTitle={activeTaskTitle}
/>
)}
</>
);
}