feat(ui): standardize responsive control sizing (#3563)
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user