Files
kandev/apps/web/components/task/mobile/session-mobile-top-bar.tsx
T
2026-09-25 10:31:38 +01:00

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>
);
});