feat(ui): standardize responsive control sizing (#3563)

This commit is contained in:
Carlos Florêncio
2026-09-10 18:14:05 +01:00
committed by GitHub
parent 8b64aed179
commit 5ffbda61a9
245 changed files with 2862 additions and 824 deletions
@@ -151,7 +151,7 @@ function DynamicRouteRecoveryActions({
<Button
type="button"
variant="outline"
className="min-h-11 min-w-11 gap-1.5"
className="gap-1.5"
disabled={pendingAction !== null}
onClick={() => onAction("retry")}
data-testid="dynamic-route-retry"
@@ -162,7 +162,7 @@ function DynamicRouteRecoveryActions({
<Button
type="button"
variant="default"
className="min-h-11 min-w-11 gap-1.5"
className="gap-1.5"
disabled={pendingAction !== null}
onClick={() => onAction("skip")}
data-testid="dynamic-route-try-next"
@@ -174,7 +174,6 @@ function DynamicRouteRecoveryActions({
<Button
type="button"
variant="outline"
className="min-h-11 min-w-11"
disabled={pendingAction !== null}
onClick={() => onAction("cancel_wait")}
data-testid="dynamic-route-cancel-wait"
@@ -185,7 +184,6 @@ function DynamicRouteRecoveryActions({
<Button
type="button"
variant="destructive"
className="min-h-11 min-w-11"
disabled={pendingAction !== null}
onClick={() => onAction("stop")}
data-testid="dynamic-route-stop"
@@ -21,6 +21,7 @@ import { useArchiveAndSwitchTask, useTaskActions } from "@/hooks/use-task-action
import { useTaskRemoval } from "@/hooks/use-task-removal";
import type { MessageAction } from "@/components/task/chat/types";
import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog";
import { controlSizingClassName } from "@kandev/ui/control-sizing";
export const ACTION_ICON_MAP: Record<string, ElementType> = {
archive: IconArchive,
@@ -87,6 +88,71 @@ type StandardActionButtonProps = Omit<ActionButtonProps, "action"> & {
action: MessageAction & { type: "archive_task" | "ws_request" };
};
type ArchiveAndSwitchTask = ReturnType<typeof useArchiveAndSwitchTask>;
async function executeStandardAction(
action: StandardActionButtonProps["action"],
taskId: string | null,
archiveAndSwitch: ArchiveAndSwitchTask,
onCompleted?: () => void,
): Promise<void> {
if (action.type === "archive_task") {
if (taskId) await archiveAndSwitch(taskId);
return;
}
const client = getWebSocketClient();
const params = action.params as { method: string; payload: Record<string, unknown> } | undefined;
// i18n-exempt: technical error for unavailable WebSocket recovery client.
if (!client || !params) throw new Error("WebSocket recovery request is unavailable");
await client.request(params.method, params.payload);
onCompleted?.();
}
type ActionButtonVisualProps = {
action: MessageAction;
compact: boolean;
disabled: boolean;
onClick: () => void;
labelOverride?: string;
destructive?: boolean;
};
function ActionButtonVisual({
action,
compact,
disabled,
onClick,
labelOverride,
destructive = false,
}: ActionButtonVisualProps): ReactElement {
const Icon = action.icon ? ACTION_ICON_MAP[action.icon] : null;
const sizingClassName = controlSizingClassName(
compact ? "compact" : "standard",
compact ? "max-md:min-h-11 [@media(pointer:coarse)]:min-h-11" : undefined,
);
return (
<Button
variant={compact ? "ghost" : "outline"}
size={compact ? "sm" : "default"}
className={cn(
compact
? "shrink-0 px-2 text-xs cursor-pointer"
: "w-full gap-1.5 text-xs cursor-pointer sm:w-auto",
sizingClassName,
destructive && "text-destructive hover:text-destructive",
)}
disabled={disabled}
onClick={onClick}
data-testid={action.test_id}
>
{Icon && <Icon className="h-3 w-3" />}
{labelOverride ?? action.label}
</Button>
);
}
function StandardActionButton({
action,
messageTaskId,
@@ -103,24 +169,8 @@ function StandardActionButton({
if (state === "busy") return;
setState("busy");
try {
switch (action.type) {
case "archive_task": {
if (taskId) await archiveAndSwitch(taskId);
break;
}
case "ws_request": {
const client = getWebSocketClient();
const params = action.params as
| { method: string; payload: Record<string, unknown> }
| undefined;
// i18n-exempt: technical error for unavailable WebSocket recovery client.
if (!client || !params) throw new Error("WebSocket recovery request is unavailable");
await client.request(params.method, params.payload);
break;
}
}
await executeStandardAction(action, taskId, archiveAndSwitch, onCompleted);
setState("done");
if (action.type === "ws_request") onCompleted?.();
} catch {
setState("error");
setTimeout(() => setState("idle"), 3000);
@@ -133,27 +183,18 @@ function StandardActionButton({
// buttons, so this stale one would just confuse the user.
if (state === "done" && action.type === "ws_request") return null;
const Icon = action.icon ? ACTION_ICON_MAP[action.icon] : null;
const disabled = state === "busy" || state === "done";
const isDestructive = action.variant === "destructive";
const button = (
<Button
variant={compact ? "ghost" : "outline"}
size="sm"
className={cn(
compact
? "h-auto min-h-11 shrink-0 px-2 text-xs cursor-pointer sm:min-h-8"
: "h-auto min-h-11 w-full gap-1.5 text-xs cursor-pointer sm:min-h-8 sm:w-auto",
isDestructive && "text-destructive hover:text-destructive",
)}
<ActionButtonVisual
action={action}
compact={compact}
disabled={disabled}
onClick={execute}
data-testid={action.test_id}
>
{Icon && <Icon className="h-3 w-3" />}
{labelOverride ?? action.label}
</Button>
labelOverride={labelOverride}
destructive={isDestructive}
/>
);
if (action.tooltip) {
@@ -171,6 +212,33 @@ type DeleteActionButtonProps = Omit<ActionButtonProps, "action"> & {
action: MessageAction & { type: "delete_task" };
};
type DeleteTaskById = ReturnType<typeof useTaskActions>["deleteTaskById"];
type RemoveTaskFromBoard = ReturnType<typeof useTaskRemoval>["removeTaskFromBoard"];
type TaskStore = ReturnType<typeof useAppStoreApi>;
async function deleteTaskAndRemoveFromBoard({
taskId,
cascade,
discardWorktreeChanges,
store,
deleteTaskById,
removeTaskFromBoard,
}: {
taskId: string;
cascade: boolean;
discardWorktreeChanges: boolean;
store: TaskStore;
deleteTaskById: DeleteTaskById;
removeTaskFromBoard: RemoveTaskFromBoard;
}): Promise<void> {
const { activeTaskId, activeSessionId } = store.getState().tasks;
await deleteTaskById(taskId, { cascade, discardWorktreeChanges });
await removeTaskFromBoard(taskId, {
wasActiveTaskId: activeTaskId,
wasActiveSessionId: activeSessionId,
});
}
function DeleteActionButton({
action,
messageTaskId,
@@ -199,11 +267,13 @@ function DeleteActionButton({
if (!taskId || state === "busy") return;
setState("busy");
try {
const { activeTaskId, activeSessionId } = store.getState().tasks;
await deleteTaskById(taskId, { cascade, discardWorktreeChanges });
await removeTaskFromBoard(taskId, {
wasActiveTaskId: activeTaskId,
wasActiveSessionId: activeSessionId,
await deleteTaskAndRemoveFromBoard({
taskId,
cascade,
discardWorktreeChanges,
store,
deleteTaskById,
removeTaskFromBoard,
});
setState("done");
} catch {
@@ -218,25 +288,16 @@ function DeleteActionButton({
if (state !== "busy") setDeleteDialogOpen(true);
}, [state]);
const Icon = action.icon ? ACTION_ICON_MAP[action.icon] : null;
const disabled = state === "busy" || state === "done";
const button = (
<Button
variant={compact ? "ghost" : "outline"}
size="sm"
className={cn(
compact
? "h-auto min-h-11 shrink-0 px-2 text-xs cursor-pointer sm:min-h-8"
: "h-auto min-h-11 w-full gap-1.5 text-xs cursor-pointer sm:min-h-8 sm:w-auto",
"text-destructive hover:text-destructive",
)}
<ActionButtonVisual
action={action}
compact={compact}
disabled={disabled}
onClick={execute}
data-testid={action.test_id}
>
{Icon && <Icon className="h-3 w-3" />}
{labelOverride ?? action.label}
</Button>
labelOverride={labelOverride}
destructive
/>
);
const dialog = (
<TaskDeleteConfirmDialog
@@ -15,6 +15,7 @@ import {
import type { SessionRecoveryAction } from "@/lib/services/session-recovery-service";
import type { MessageAction } from "@/components/task/chat/types";
import { ACTION_ICON_MAP, ActionButton } from "./action-message-actions";
import { controlSizingClassName } from "@kandev/ui/control-sizing";
export function sessionRecoveryAction(action: MessageAction): SessionRecoveryAction | null {
if (action.type !== "ws_request" || !action.params) return null;
@@ -130,8 +131,10 @@ function SessionRecoveryActionButton({
const button = (
<Button
variant="outline"
size="sm"
className="h-auto min-h-11 w-full gap-1.5 text-xs cursor-pointer sm:min-h-8 sm:w-auto"
className={controlSizingClassName(
"standard",
"w-full gap-1.5 text-xs cursor-pointer sm:w-auto",
)}
disabled={busyAction !== null}
onClick={onClick}
data-testid={action.test_id}
@@ -374,7 +374,8 @@ describe("ActionMessage — running stall notice", () => {
expect(notice.className).not.toContain("text-red");
expect(notice.querySelector("svg")).toBeNull();
const button = screen.getByTestId(STALL_CANCEL_TEST_ID);
expect(button.className).toContain("min-h-11");
expect(button.className).toContain("h-6");
expect(button.className).toContain("max-md:min-h-11");
expect(button.className).not.toContain("w-full");
});
@@ -453,8 +454,8 @@ describe("ActionMessage — missing PR branch", () => {
expect(screen.getByText("codex/enhance-prompt-result-delivery")).toBeTruthy();
const technicalDetails = screen.getByText(TECHNICAL_DETAILS).closest("details");
expect(technicalDetails?.open).toBe(false);
expect(screen.getByTestId("missing-branch-archive-button").className).toContain("min-h-11");
expect(screen.getByTestId("missing-branch-delete-button").className).toContain("min-h-11");
expect(screen.getByTestId("missing-branch-archive-button").className).toContain("h-7");
expect(screen.getByTestId("missing-branch-delete-button").className).toContain("h-7");
fireEvent.click(screen.getByText(TECHNICAL_DETAILS));
expect(technicalDetails?.open).toBe(true);
@@ -521,7 +522,8 @@ describe("ActionMessage — provider quota recovery", () => {
expect(screen.getByText(/kimi-k3/i)).toBeTruthy();
const details = screen.getByText(TECHNICAL_DETAILS).closest("details");
expect(details?.open).toBe(false);
expect(screen.getByTestId(RESUME_TEST_ID).className).toContain("min-h-11");
expect(screen.getByTestId(RESUME_TEST_ID).className).toContain("h-7");
expect(screen.getByTestId(RESUME_TEST_ID).className).toContain("max-md:h-11");
fireEvent.click(screen.getByText(TECHNICAL_DETAILS));
expect(details?.open).toBe(true);
@@ -640,7 +642,8 @@ describe("ActionMessage — remediation link", () => {
expect(link.href).toBe(REMEDIATION_URL);
expect(link.target).toBe("_blank");
expect(link.rel).toBe("noopener noreferrer");
expect(link.className).toContain("min-h-11");
expect(link.className).toContain("h-7");
expect(link.className).toContain("max-md:h-11");
// The sanitized message and collapsed details stay URL-free.
expect(screen.queryByText(/opencode\.ai\/workspace/i)).toBeNull();
expect(screen.getByTestId("provider-quota-recovery").textContent).toContain(QUOTA_OUTPUT);
@@ -14,6 +14,7 @@ import { createXAxisTickFormatter, formatYAxisTick } from "./chart-format";
import { useRichOutputChartAnimations } from "./chart-motion";
import { useChartPlotVisibility } from "./chart-visibility";
import type { RichOutputChartBlock } from "./types";
import { controlSizingClassName } from "@kandev/ui/control-sizing";
const HOST_SERIES_COLORS = [
"var(--chart-1)",
@@ -85,7 +86,7 @@ function SeriesLegend({
key={key}
type="button"
aria-pressed={isVisible}
className={`flex min-h-11 cursor-pointer items-center gap-1.5 rounded-md px-2 text-[11px] text-muted-foreground transition-[opacity,transform] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 active:scale-[0.98] ${isVisible ? "opacity-100" : "opacity-50"}`}
className={`${controlSizingClassName("standard")} flex cursor-pointer items-center gap-1.5 rounded-md px-2 text-[11px] text-muted-foreground transition-[opacity,transform] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 active:scale-[0.98] ${isVisible ? "opacity-100" : "opacity-50"}`}
data-testid={`rich-output-chart-legend-${key}`}
onClick={() => onToggle(key)}
>
@@ -111,8 +111,7 @@ export function FilePreviewBlock({ block, sessionId, onOpenFile }: FilePreviewBl
<Button
type="button"
variant="ghost"
size="sm"
className="min-h-11 cursor-pointer gap-1.5 px-2.5 min-[640px]:min-h-9"
className="cursor-pointer gap-1.5 px-2.5"
aria-expanded={expanded}
aria-controls={previewId}
onClick={handleToggle}
@@ -129,8 +128,7 @@ export function FilePreviewBlock({ block, sessionId, onOpenFile }: FilePreviewBl
<Button
type="button"
variant="ghost"
size="sm"
className="min-h-11 cursor-pointer gap-1.5 px-2.5 min-[640px]:min-h-9"
className="cursor-pointer gap-1.5 px-2.5"
onClick={() => onOpenFile(block.path, block.repo)}
data-testid="rich-output-file-open"
>
@@ -234,9 +234,8 @@ function CompletedSessionActions({
>
<Button
variant="default"
size="sm"
data-testid="recovery-resume-button"
className="min-h-11 w-full shrink-0 gap-1.5 cursor-pointer sm:w-auto"
className="w-full shrink-0 gap-1.5 cursor-pointer sm:w-auto"
onClick={handleResume}
disabled={busyAction !== null || !profileExists}
>
@@ -252,8 +251,7 @@ function CompletedSessionActions({
)}
<Button
variant="outline"
size="sm"
className="min-h-11 w-full shrink-0 gap-1.5 cursor-pointer sm:w-auto"
className="w-full shrink-0 gap-1.5 cursor-pointer sm:w-auto"
onClick={() => {
if (taskId) onShowDialog(true);
}}
@@ -300,9 +298,8 @@ function RecoverableSessionButtons({
>
<Button
variant="default"
size="sm"
data-testid="recovery-resume-button"
className="min-h-11 w-full shrink-0 gap-1.5 cursor-pointer sm:w-auto"
className="w-full shrink-0 gap-1.5 cursor-pointer sm:w-auto"
onClick={onResume}
disabled={busyAction !== null || !profileExists}
>
@@ -318,8 +315,7 @@ function RecoverableSessionButtons({
)}
<Button
variant="outline"
size="sm"
className="min-h-11 w-full shrink-0 gap-1.5 cursor-pointer sm:w-auto"
className="w-full shrink-0 gap-1.5 cursor-pointer sm:w-auto"
onClick={onFreshStart}
disabled={busyAction !== null}
data-testid="recovery-fresh-button"