396 lines
13 KiB
TypeScript
396 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import { memo } from "react";
|
|
import Link from "@/components/routing/app-link";
|
|
import {
|
|
IconArrowLeft,
|
|
IconChevronDown,
|
|
IconChevronRight,
|
|
IconGitBranch,
|
|
IconCheck,
|
|
} from "@tabler/icons-react";
|
|
import { Button } from "@kandev/ui/button";
|
|
import { RemoteCloudTooltip } from "@/components/task/remote-cloud-tooltip";
|
|
import { ExecutorSettingsButton } from "@/components/task/executor-settings-button";
|
|
import { LineStat } from "@/components/diff-stat";
|
|
import { useSessionGitStatus } from "@/hooks/domains/session/use-session-git-status";
|
|
import { useSessionCommits } from "@/hooks/domains/session/use-session-commits";
|
|
import type { FileInfo } from "@/lib/state/slices";
|
|
import {
|
|
TaskTopBarPluginActions,
|
|
useHasTaskTopBarPluginActions,
|
|
} from "@/components/task/task-top-bar-plugin-actions";
|
|
import { TaskUnarchiveButton } from "@/components/task/task-unarchive-button";
|
|
import { MRTopbarButton } from "@/components/gitlab/mr-topbar-button";
|
|
import { PortForwardButton } from "@/components/task/port-forward-dialog";
|
|
import { linkToTaskOverview } from "@/lib/links";
|
|
import { AppNavSheet } from "@/components/navigation/app-nav-sheet";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
RemoteRepositoryProviderIcon,
|
|
useRemoteRepositoryProviderLabel,
|
|
} from "@/components/task-create-dialog-remote-repo-provider-tabs";
|
|
import type { TaskTopbarRepository } from "../task-page-content-helpers";
|
|
|
|
type SessionMobileTopBarProps = {
|
|
taskId?: string | null;
|
|
workspaceId?: string | null;
|
|
taskTitle?: string;
|
|
/** `owner/repo` (or the repository name) of the task's primary repository. */
|
|
repositoryLabel?: string | null;
|
|
topbarRepository?: TaskTopbarRepository | null;
|
|
sessionId?: string | null;
|
|
baseBranch?: string;
|
|
worktreeBranch?: string | null;
|
|
onTaskPickerClick: () => void;
|
|
taskPickerOpen: boolean;
|
|
showApproveButton?: boolean;
|
|
onApprove?: () => void;
|
|
isRemoteExecutor?: boolean;
|
|
remoteExecutorType?: string | null;
|
|
remoteExecutorName?: string | null;
|
|
remoteState?: string | null;
|
|
remoteCreatedAt?: string | null;
|
|
remoteCheckedAt?: string | null;
|
|
remoteStatusError?: string | null;
|
|
isArchived?: boolean;
|
|
onTaskUnarchived?: (taskId: string) => void;
|
|
};
|
|
|
|
function MobileTaskTitle({
|
|
taskTitle,
|
|
repositoryLabel,
|
|
displayBranch,
|
|
totalAdditions,
|
|
totalDeletions,
|
|
onTaskPickerClick,
|
|
taskPickerOpen,
|
|
}: {
|
|
taskTitle?: string;
|
|
repositoryLabel?: string | null;
|
|
displayBranch?: string;
|
|
totalAdditions: number;
|
|
totalDeletions: number;
|
|
onTaskPickerClick: () => void;
|
|
taskPickerOpen: boolean;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<button
|
|
type="button"
|
|
className="flex h-11 min-w-11 flex-1 flex-col justify-center rounded-md text-left cursor-pointer hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring"
|
|
onClick={(event) => {
|
|
event.currentTarget.focus();
|
|
onTaskPickerClick();
|
|
}}
|
|
aria-haspopup="dialog"
|
|
aria-expanded={taskPickerOpen}
|
|
aria-label={t("task:openTaskSwitcherFor", { title: taskTitle ?? t("task:taskDetails") })}
|
|
data-testid="mobile-task-picker-trigger"
|
|
data-legacy-testid="mobile-session-menu"
|
|
>
|
|
<span className="flex w-full min-w-0 items-center gap-1">
|
|
<span className="text-sm font-medium truncate">{taskTitle ?? t("task:taskDetails")}</span>
|
|
<IconChevronDown className="size-4 shrink-0" />
|
|
</span>
|
|
{/* The phone bar has no breadcrumb, so the repository rides the same
|
|
secondary line as the branch. It shrinks first: on a phone the branch
|
|
and diff stats are the denser signal, and the full name stays in
|
|
`title`. */}
|
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
{repositoryLabel && (
|
|
<span
|
|
data-testid="mobile-task-repository"
|
|
title={repositoryLabel}
|
|
className="min-w-0 truncate text-xs text-muted-foreground/70"
|
|
>
|
|
{repositoryLabel}
|
|
</span>
|
|
)}
|
|
{displayBranch && (
|
|
<>
|
|
<IconGitBranch className="h-3 w-3 shrink-0 text-muted-foreground" />
|
|
<span className="shrink-0 max-w-[45%] truncate text-xs text-muted-foreground">
|
|
{displayBranch}
|
|
</span>
|
|
{(totalAdditions > 0 || totalDeletions > 0) && (
|
|
<LineStat added={totalAdditions} removed={totalDeletions} />
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function MobileRepositoryLabel({ repository }: { repository: TaskTopbarRepository }) {
|
|
const { t } = useTranslation();
|
|
const providerLabel = useRemoteRepositoryProviderLabel(repository.provider);
|
|
const accessibleName = t("task:remoteRepositoryIdentity", {
|
|
provider: providerLabel,
|
|
repository: repository.fullName,
|
|
});
|
|
const icon = (
|
|
<span
|
|
data-testid="mobile-task-repository-provider-icon"
|
|
aria-hidden="true"
|
|
className="flex shrink-0 items-center"
|
|
>
|
|
<RemoteRepositoryProviderIcon provider={repository.provider} />
|
|
</span>
|
|
);
|
|
const name = (
|
|
<span data-testid="mobile-task-repository-name" aria-hidden="true" className="min-w-0 truncate">
|
|
{repository.displayName}
|
|
</span>
|
|
);
|
|
const className =
|
|
"flex h-11 min-h-11 min-w-11 max-w-[38%] shrink items-center gap-1 rounded-md px-1.5 text-xs text-muted-foreground";
|
|
const content = (
|
|
<span className="flex min-w-0 items-center gap-1">
|
|
{icon}
|
|
{name}
|
|
</span>
|
|
);
|
|
|
|
if (repository.browserUrl) {
|
|
return (
|
|
<a
|
|
href={repository.browserUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
aria-label={accessibleName}
|
|
title={repository.fullName}
|
|
data-testid="mobile-task-repository-link"
|
|
className={`${className} cursor-pointer hover:bg-muted hover:text-foreground`}
|
|
>
|
|
{content}
|
|
</a>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<span
|
|
title={repository.fullName}
|
|
data-testid="mobile-task-repository-label"
|
|
className={`${className} cursor-default`}
|
|
>
|
|
{content}
|
|
<span className="sr-only">{accessibleName}</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export function MobileRemoteExecutorIndicator({
|
|
taskId,
|
|
sessionId,
|
|
remoteExecutorType,
|
|
remoteExecutorName,
|
|
remoteState,
|
|
remoteCreatedAt,
|
|
remoteCheckedAt,
|
|
remoteStatusError,
|
|
}: {
|
|
taskId?: string | null;
|
|
sessionId?: string | null;
|
|
remoteExecutorType?: string | null;
|
|
remoteExecutorName?: string | null;
|
|
remoteState?: string | null;
|
|
remoteCreatedAt?: string | null;
|
|
remoteCheckedAt?: string | null;
|
|
remoteStatusError?: string | null;
|
|
}) {
|
|
if (remoteExecutorType === "k8s") {
|
|
return <ExecutorSettingsButton taskId={taskId} sessionId={sessionId} />;
|
|
}
|
|
return (
|
|
<RemoteCloudTooltip
|
|
taskId={taskId ?? ""}
|
|
sessionId={sessionId}
|
|
executorType={remoteExecutorType}
|
|
fallbackName={remoteExecutorName ?? remoteExecutorType}
|
|
iconClassName="h-4 w-4"
|
|
status={{
|
|
remote_name: remoteExecutorName ?? undefined,
|
|
remote_state: remoteState ?? undefined,
|
|
remote_created_at: remoteCreatedAt ?? undefined,
|
|
remote_checked_at: remoteCheckedAt ?? undefined,
|
|
remote_status_error: remoteStatusError ?? undefined,
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function ApproveButton({ onApprove }: { onApprove: () => void }) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<Button
|
|
size="sm"
|
|
className="h-7 gap-1 px-2 cursor-pointer bg-emerald-600 hover:bg-emerald-700 text-white text-xs"
|
|
onClick={onApprove}
|
|
>
|
|
<IconCheck className="h-3.5 w-3.5" />
|
|
{t("task:approve")}
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
function computeUncommittedStats(files: Record<string, FileInfo> | undefined) {
|
|
let additions = 0;
|
|
let deletions = 0;
|
|
for (const file of Object.values(files ?? {})) {
|
|
additions += file.additions || 0;
|
|
deletions += file.deletions || 0;
|
|
}
|
|
return { additions, deletions };
|
|
}
|
|
|
|
function useMobileGitMetrics(
|
|
sessionId: string | null | undefined,
|
|
worktreeBranch: string | null | undefined,
|
|
baseBranch: string | undefined,
|
|
) {
|
|
const gitStatus = useSessionGitStatus(sessionId ?? null);
|
|
const { commits } = useSessionCommits(sessionId ?? null);
|
|
const stats = computeUncommittedStats(gitStatus?.files);
|
|
|
|
return {
|
|
displayBranch: worktreeBranch || baseBranch,
|
|
totalAdditions: stats.additions + commits.reduce((sum, commit) => sum + commit.insertions, 0),
|
|
totalDeletions: stats.deletions + commits.reduce((sum, commit) => sum + commit.deletions, 0),
|
|
};
|
|
}
|
|
|
|
type MobileTopBarActionsProps = {
|
|
taskId?: string | null;
|
|
workspaceId?: string | null;
|
|
isRemoteExecutor?: boolean;
|
|
remoteExecutorType?: string | null;
|
|
remoteExecutorName?: string | null;
|
|
remoteState?: string | null;
|
|
remoteCreatedAt?: string | null;
|
|
remoteCheckedAt?: string | null;
|
|
remoteStatusError?: string | null;
|
|
showApproveButton: boolean;
|
|
onApprove?: () => void;
|
|
sessionId?: string | null;
|
|
taskTitle?: string;
|
|
isArchived?: boolean;
|
|
onTaskUnarchived?: (taskId: string) => void;
|
|
onTaskPickerClick: () => void;
|
|
};
|
|
|
|
function MobileTopBarActions({
|
|
taskId,
|
|
workspaceId,
|
|
isRemoteExecutor,
|
|
remoteExecutorType,
|
|
remoteExecutorName,
|
|
remoteState,
|
|
remoteCreatedAt,
|
|
remoteCheckedAt,
|
|
remoteStatusError,
|
|
showApproveButton,
|
|
onApprove,
|
|
sessionId,
|
|
taskTitle,
|
|
isArchived,
|
|
onTaskUnarchived,
|
|
onTaskPickerClick,
|
|
}: MobileTopBarActionsProps) {
|
|
const hasPluginActions = useHasTaskTopBarPluginActions();
|
|
const pluginActions =
|
|
!isArchived && hasPluginActions ? (
|
|
<TaskTopBarPluginActions
|
|
sessionId={sessionId ?? null}
|
|
taskId={taskId ?? null}
|
|
taskTitle={taskTitle}
|
|
workspaceId={workspaceId ?? null}
|
|
presentation="mobile"
|
|
/>
|
|
) : undefined;
|
|
|
|
return (
|
|
<div className="flex shrink-0 items-center gap-1" data-testid="mobile-topbar-actions">
|
|
<MRTopbarButton compact mobile />
|
|
{isArchived && <TaskUnarchiveButton taskId={taskId} onUnarchived={onTaskUnarchived} mobile />}
|
|
{!isArchived && <PortForwardButton sessionId={sessionId} />}
|
|
{isRemoteExecutor && (
|
|
<MobileRemoteExecutorIndicator
|
|
taskId={taskId}
|
|
sessionId={sessionId}
|
|
remoteExecutorType={remoteExecutorType}
|
|
remoteExecutorName={remoteExecutorName}
|
|
remoteState={remoteState}
|
|
remoteCreatedAt={remoteCreatedAt}
|
|
remoteCheckedAt={remoteCheckedAt}
|
|
remoteStatusError={remoteStatusError}
|
|
/>
|
|
)}
|
|
{showApproveButton && onApprove && <ApproveButton onApprove={onApprove} />}
|
|
<AppNavSheet onOpenTaskViews={onTaskPickerClick} pluginActions={pluginActions} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export const SessionMobileTopBar = memo(function SessionMobileTopBar(
|
|
props: SessionMobileTopBarProps,
|
|
) {
|
|
const { t } = useTranslation();
|
|
const { displayBranch, totalAdditions, totalDeletions } = useMobileGitMetrics(
|
|
props.sessionId,
|
|
props.worktreeBranch,
|
|
props.baseBranch,
|
|
);
|
|
return (
|
|
<header className="flex items-center justify-between h-14 px-2 py-1 bg-background">
|
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
|
<Button variant="ghost" size="icon-sm" asChild>
|
|
<Link
|
|
href={linkToTaskOverview({ workspaceId: props.workspaceId ?? undefined })}
|
|
aria-label={t("task:taskOverview")}
|
|
>
|
|
<IconArrowLeft className="h-4 w-4" />
|
|
</Link>
|
|
</Button>
|
|
{props.topbarRepository && (
|
|
<>
|
|
<MobileRepositoryLabel repository={props.topbarRepository} />
|
|
<IconChevronRight
|
|
aria-hidden="true"
|
|
className="size-3.5 shrink-0 text-muted-foreground"
|
|
/>
|
|
</>
|
|
)}
|
|
<MobileTaskTitle
|
|
taskTitle={props.taskTitle}
|
|
repositoryLabel={props.topbarRepository ? null : props.repositoryLabel}
|
|
displayBranch={displayBranch}
|
|
totalAdditions={totalAdditions}
|
|
totalDeletions={totalDeletions}
|
|
onTaskPickerClick={props.onTaskPickerClick}
|
|
taskPickerOpen={props.taskPickerOpen}
|
|
/>
|
|
</div>
|
|
<MobileTopBarActions
|
|
taskId={props.taskId}
|
|
workspaceId={props.workspaceId}
|
|
isRemoteExecutor={props.isRemoteExecutor}
|
|
remoteExecutorType={props.remoteExecutorType}
|
|
remoteExecutorName={props.remoteExecutorName}
|
|
remoteState={props.remoteState}
|
|
remoteCreatedAt={props.remoteCreatedAt}
|
|
remoteCheckedAt={props.remoteCheckedAt}
|
|
remoteStatusError={props.remoteStatusError}
|
|
showApproveButton={props.showApproveButton ?? false}
|
|
onApprove={props.onApprove}
|
|
sessionId={props.sessionId}
|
|
taskTitle={props.taskTitle}
|
|
isArchived={props.isArchived}
|
|
onTaskUnarchived={props.onTaskUnarchived}
|
|
onTaskPickerClick={props.onTaskPickerClick}
|
|
/>
|
|
</header>
|
|
);
|
|
});
|