fix(app): give subagent rows room on compact and standard press feedback (#7364)

The subagent track rows were as dense on a phone as in the desktop popover,
and the archive and detach actions were hand-rolled pressables that only
recoloured the glyph, so a tap or hover never showed the rounded highlight
every other icon action has.

Rows now take the compact menu-row height and padding below the `md`
breakpoint, decided once in the track row style; the desktop popover keeps
its density. The actions are `ToolbarButton`, which owns the hitbox, the
rounded highlight on hover and press, the compact size, and the tooltip.

A repo oxlint rule, `paseo/no-raw-pressable`, reports `Pressable` and
`Touchable*` imports outside `components/ui/` so the next tap target reaches
for a primitive. Existing importers are on a burn-down list they can only
leave. The rule is its own name rather than another `no-restricted-imports`
entry so its override cannot interact with the existing burn-downs. Its test
runs the real oxlint binary and is wired into the CI quality job.
This commit is contained in:
Mohamed Boudra
2026-10-19 12:24:14 +07:00
committed by GitHub
parent 644d66ba4f
commit d61dcaf1df
8 changed files with 278 additions and 78 deletions
+4
View File
@@ -93,6 +93,10 @@ jobs:
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
run: npm run lint
- name: Test lint rules
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
run: node --test scripts/oxlint/paseo-plugin.test.mjs
- name: Build relay
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.relay != 'false') }}
run: npm run build:relay
+112
View File
@@ -5,6 +5,7 @@
},
"ignorePatterns": [".dev/**"],
"plugins": ["react", "react-perf", "unicorn", "typescript", "oxc", "import", "promise"],
"jsPlugins": ["./scripts/oxlint/paseo-plugin.mjs"],
"categories": {
"correctness": "error",
"suspicious": "error",
@@ -356,6 +357,117 @@
"no-restricted-imports": "off"
}
},
{
"files": ["packages/app/src/**/*.{ts,tsx}"],
"rules": {
"paseo/no-raw-pressable": "error"
}
},
// Press feedback is owned by the primitives in components/ui. Raw pressable burn-down:
// 94 files. They may only leave this list.
{
"files": [
"packages/app/src/components/ui/**/*.{ts,tsx}",
"packages/app/src/**/*.{test,spec}.{ts,tsx}",
"packages/app/src/agent-profiles/settings/agent-profile-appearance-field.tsx",
"packages/app/src/agent-skills/selection-sheet.tsx",
"packages/app/src/agent-stream/chat-outline/rail.web.tsx",
"packages/app/src/agent-stream/view.tsx",
"packages/app/src/app/pair-scan.tsx",
"packages/app/src/changelog/internal/changelog-sheet.tsx",
"packages/app/src/command-center/command-center.tsx",
"packages/app/src/components/adaptive-modal-sheet.tsx",
"packages/app/src/components/add-host-method-modal.tsx",
"packages/app/src/components/add-host-modal.tsx",
"packages/app/src/components/add-project-flow.tsx",
"packages/app/src/components/add-remote-ssh-host-modal.tsx",
"packages/app/src/components/agent-list.tsx",
"packages/app/src/components/app-diagnostic-sheet.tsx",
"packages/app/src/components/attachment-lightbox.tsx",
"packages/app/src/components/attachment-pill.tsx",
"packages/app/src/components/combined-model-selector.tsx",
"packages/app/src/components/compact-explorer-sidebar.tsx",
"packages/app/src/components/context-window-meter.tsx",
"packages/app/src/components/desktop/window-controls.tsx",
"packages/app/src/components/dictation-controls.tsx",
"packages/app/src/components/download-toast.tsx",
"packages/app/src/components/file-explorer-pane.tsx",
"packages/app/src/components/headers/back-header.tsx",
"packages/app/src/components/highlighted-code-block.tsx",
"packages/app/src/components/hosts/host-filter.tsx",
"packages/app/src/components/hosts/host-picker.tsx",
"packages/app/src/components/import-session-sheet.tsx",
"packages/app/src/components/left-sidebar.tsx",
"packages/app/src/components/markdown/fence/mermaid/host.native.tsx",
"packages/app/src/components/markdown/fence/mermaid/host.web.tsx",
"packages/app/src/components/message.tsx",
"packages/app/src/components/model-browser.tsx",
"packages/app/src/components/plan-card.tsx",
"packages/app/src/components/provider-diagnostic-sheet.tsx",
"packages/app/src/components/question-form-card.tsx",
"packages/app/src/components/realtime-voice-overlay.tsx",
"packages/app/src/components/schedules/schedule-row.tsx",
"packages/app/src/components/settings/collapsible-row.tsx",
"packages/app/src/components/settings/headings/settings-info-tip.tsx",
"packages/app/src/components/sidebar-callout.tsx",
"packages/app/src/components/sidebar-resize-handle.tsx",
"packages/app/src/components/sidebar-workspace-list.tsx",
"packages/app/src/components/sidebar/sidebar-group-toggle-row.tsx",
"packages/app/src/components/sidebar/sidebar-header-row.tsx",
"packages/app/src/components/sidebar/sidebar-section-header.tsx",
"packages/app/src/components/sidebar/sidebar-status-list.tsx",
"packages/app/src/components/sidebar/workspace-meta-row/index.tsx",
"packages/app/src/components/terminal-copy-paste-actions.tsx",
"packages/app/src/components/terminal-emulator-native-grid.native.tsx",
"packages/app/src/components/terminal-pane.tsx",
"packages/app/src/components/toast-host.tsx",
"packages/app/src/components/tool-call-sheet.tsx",
"packages/app/src/components/welcome-screen.tsx",
"packages/app/src/components/workspace-hover-card.tsx",
"packages/app/src/components/zoomable-viewport/toolbar.tsx",
"packages/app/src/composer/agent-controls/index.tsx",
"packages/app/src/composer/diff-stat-pill.tsx",
"packages/app/src/composer/dock/internal/background.native.tsx",
"packages/app/src/composer/draft/import-pill.tsx",
"packages/app/src/composer/index.tsx",
"packages/app/src/composer/tracks.tsx",
"packages/app/src/desktop/browser/pane/index.electron.tsx",
"packages/app/src/git/actions-split-button.tsx",
"packages/app/src/git/commits-section/commit-row.tsx",
"packages/app/src/git/commits-section/commits-section.tsx",
"packages/app/src/git/diff-pane.tsx",
"packages/app/src/git/forges/gitlab.view.tsx",
"packages/app/src/git/pull-request-panel/checks-section.tsx",
"packages/app/src/git/pull-request-panel/pane.tsx",
"packages/app/src/git/pull-request-panel/section-kit.tsx",
"packages/app/src/hosts/host-chooser.tsx",
"packages/app/src/mobile-panels/presentation.tsx",
"packages/app/src/panels/new-tab-panel.tsx",
"packages/app/src/panels/setup-panel.tsx",
"packages/app/src/plugins/buttons/view.tsx",
"packages/app/src/review/surface.tsx",
"packages/app/src/screens/open-project-screen.tsx",
"packages/app/src/screens/project-settings-screen.tsx",
"packages/app/src/screens/projects-screen.tsx",
"packages/app/src/screens/settings-screen.tsx",
"packages/app/src/screens/settings/host-appearance-section.tsx",
"packages/app/src/screens/settings/host-page.tsx",
"packages/app/src/screens/settings/providers-section.tsx",
"packages/app/src/screens/workspace/compact-workspace-header/tab-switcher.tsx",
"packages/app/src/screens/workspace/scripts/workspace-scripts-button.tsx",
"packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx",
"packages/app/src/screens/workspace/workspace-tab-presentation.tsx",
"packages/app/src/tool-calls/detail-level/overview/sheet.tsx",
"packages/app/src/usage/sidebar-item.tsx",
"packages/app/src/usage/window-bar.tsx",
"packages/app/src/workspace-labels/manager-modal.tsx",
"packages/app/src/workspace-labels/swatch.tsx",
"packages/app/src/workspace/open-in-editor/button.tsx"
],
"rules": {
"paseo/no-raw-pressable": "off"
}
},
{
"files": ["**/e2e/**/fixtures.ts"],
"rules": {
+2 -1
View File
@@ -20,7 +20,7 @@ A semantic element used in three or more places is a primitive. One of a kind is
Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`.
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/screens/settings/settings-section.tsx`). A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A pressable wrapping an icon is wrong; the icon action is `<ToolbarButton>` (`packages/app/src/components/ui/pane-content-toolbar.tsx`), which owns the hitbox, the rounded `interactionHighlight` fill on hover and press, the compact size, and the tooltip. Rows on a menu surface are `<MenuItem>`, or `<ComposerTrackRow>` in a track panel; other rows are `<PressHighlight>` (`packages/app/src/components/ui/press-highlight.tsx`), which flashes on native the moment a finger lands. A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/screens/settings/settings-section.tsx`). A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
Before adding a new component, read `components/ui/`. The primitive usually exists.
@@ -249,6 +249,7 @@ New status pills use `<StatusBadge>`. Identity, shortcut, and interactive link b
- `fontWeight.medium` on row titles, body text, button labels, badge text, or `<SidebarCallout>` titles. Medium is reserved for the structural-label tier described in §3 — section labels, modal/sheet titles, dense metadata emphasis, and tight action labels. Anything else is `normal`. `<ScreenTitle>` is responsive `400/300` and is never overridden.
- `<Pressable>` wrapping `<Text>` to make a button. `<Button>` exists.
- Importing `Pressable` or a `Touchable*` outside `components/ui/`. A raw pressable has no standard press or hover feedback, so every one drifts. `paseo/no-raw-pressable` reports it; the files on its burn-down list in `.oxlintrc.json` may only leave it.
- Bare `<Text>` for a section header inside settings. `<SettingsSection>` exists.
- A muted paragraph between a section header and its card. Section-level explanation is the header's `info` tooltip (§7).
- A "Settings" CTA on a detail page. Detail pages are settings; settings is reached from the sidebar, the host entry, or a row's kebab menu.
+19 -23
View File
@@ -2,6 +2,7 @@ import { useCallback, useMemo, useState, type ReactElement, type ReactNode } fro
import { Pressable, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import {
MENU_ITEM_HEIGHT,
MenuRoot,
MenuSeparator,
MenuSurface,
@@ -312,6 +313,22 @@ const styles = StyleSheet.create((theme) => {
backgroundColor: getStatusDotColor({ theme, bucket, showDoneAsInactive: true }) ?? undefined,
});
// The rail every panel row sits on: inset from the panel edge so the fill is a rounded block
// inside it, and tall enough that revealing an action button cannot resize the row.
//
// Compact and desktop differ here and only here. A finger needs the taller row a menu row gets
// on compact; a pointer does not, and the popover keeps its density.
const trackRow = {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minHeight: { xs: MENU_ITEM_HEIGHT.xs, md: 32 },
marginHorizontal: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
paddingVertical: { xs: theme.spacing[2], md: theme.spacing[1] },
borderRadius: theme.borderRadius.md,
} as const;
return {
bar: {
position: "absolute",
@@ -332,29 +349,8 @@ const styles = StyleSheet.create((theme) => {
alignItems: "center",
gap: theme.spacing[1],
},
// The rail every panel row sits on: inset from the panel edge so the fill is a rounded block
// inside it, and tall enough that revealing an action button cannot resize the row.
row: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minHeight: 32,
marginHorizontal: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.md,
},
rowActive: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minHeight: 32,
marginHorizontal: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface2,
},
row: trackRow,
rowActive: { ...trackRow, backgroundColor: theme.colors.surface2 },
// Segments sit twice as far apart as a mark sits from its own text, so a count reads as
// belonging to the mark on its left rather than to the words on its right.
segments: {
+44 -54
View File
@@ -1,11 +1,12 @@
import { useCallback, type ReactElement } from "react";
import { Pressable, Text, View } from "react-native";
import { useCallback, useMemo, type ReactElement, type ReactNode } from "react";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { Archive, Unlink } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useProviderIcon } from "@/components/provider-icons";
import { ComposerTrackActions, ComposerTrackPill, ComposerTrackRow } from "@/composer/tracks";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { mutedIconColorMapping } from "@/components/ui/icon-button-chrome";
import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import {
@@ -49,7 +50,6 @@ export interface SubagentsTrackProps {
const IDLE_ARCHIVE_FINISHED_STATUS: ArchiveFinishedStatus = { kind: "idle" };
/** Leading and action glyphs share one size so rows keep a single icon column. */
const ROW_ICON_SIZE = 14;
function useRowPresentation(row: SubagentRow, serverId: string): WorkspaceTabPresentation {
@@ -234,6 +234,7 @@ function SubagentsTrackRow({
<SubagentRowActions
rowId={row.id}
displayLabel={displayLabel}
compact={isCompact}
visible={actionsAlwaysVisible || active}
onDetachPress={onDetachSubagent ? handleDetachPress : undefined}
onArchivePress={handleArchivePress}
@@ -246,6 +247,7 @@ function SubagentsTrackRow({
displayLabel,
handleArchivePress,
handleDetachPress,
isCompact,
onDetachSubagent,
presentation,
row.kind,
@@ -284,17 +286,20 @@ function SubagentFacts({ rowId, text }: { rowId: string; text: string | null })
function SubagentRowActions({
rowId,
displayLabel,
compact,
visible,
onDetachPress,
onArchivePress,
}: {
rowId: string;
displayLabel: string;
compact: boolean;
visible: boolean;
onDetachPress?: () => void;
onArchivePress: () => void;
}): ReactElement {
const { t } = useTranslation();
const iconSize = paneContentToolbarIconSize(compact);
return (
<View
style={visible ? styles.actionClusterVisible : styles.actionClusterHidden}
@@ -302,69 +307,59 @@ function SubagentRowActions({
>
{onDetachPress ? (
<SubagentActionButton
accessibilityLabel={t("subagents.detachAction", { label: displayLabel })}
testID={`subagents-track-detach-${rowId}`}
label={t("subagents.detachAction", { label: displayLabel })}
tooltipLabel={t("subagents.detachTooltip")}
icon="detach"
visible={visible}
compact={compact}
onPress={onDetachPress}
/>
testID={`subagents-track-detach-${rowId}`}
>
<ThemedUnlink size={iconSize} uniProps={mutedIconColorMapping} />
</SubagentActionButton>
) : null}
<SubagentActionButton
accessibilityLabel={t("subagents.archiveAction", { label: displayLabel })}
testID={`subagents-track-archive-${rowId}`}
label={t("subagents.archiveAction", { label: displayLabel })}
tooltipLabel={t("subagents.archiveTooltip")}
icon="archive"
visible={visible}
compact={compact}
onPress={onArchivePress}
/>
testID={`subagents-track-archive-${rowId}`}
>
<ThemedArchive size={iconSize} uniProps={mutedIconColorMapping} />
</SubagentActionButton>
</View>
);
}
type SubagentActionIcon = "archive" | "detach";
function renderSubagentActionIcon(icon: SubagentActionIcon, isActive: boolean): ReactElement {
const uniProps = isActive ? foregroundColorMapping : foregroundMutedColorMapping;
if (icon === "detach") {
return <ThemedUnlink size={ROW_ICON_SIZE} uniProps={uniProps} />;
}
return <ThemedArchive size={ROW_ICON_SIZE} uniProps={uniProps} />;
}
/** The standard icon action, with a tooltip that names the action without repeating the row. */
function SubagentActionButton({
accessibilityLabel,
testID,
label,
tooltipLabel,
icon,
visible,
compact,
onPress,
testID,
children,
}: {
accessibilityLabel: string;
testID: string;
label: string;
tooltipLabel: string;
icon: SubagentActionIcon;
visible: boolean;
compact: boolean;
onPress: () => void;
testID: string;
children: ReactNode;
}): ReactElement {
const tooltip = useMemo(
() => <Text style={styles.tooltipText}>{tooltipLabel}</Text>,
[tooltipLabel],
);
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild disabled={!visible}>
<Pressable
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
testID={testID}
onPress={onPress}
style={styles.actionButton}
hitSlop={8}
>
{({ hovered, pressed }) => renderSubagentActionIcon(icon, hovered || pressed)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{tooltipLabel}</Text>
</TooltipContent>
</Tooltip>
<ToolbarButton
label={label}
tooltip={tooltip}
tooltipSide="top"
compact={compact}
onPress={onPress}
testID={testID}
>
{children}
</ToolbarButton>
);
}
@@ -415,11 +410,6 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[1],
opacity: 0,
},
actionButton: {
padding: theme.spacing[1],
alignItems: "center",
justifyContent: "center",
},
tooltipText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
+1
View File
@@ -379,6 +379,7 @@ test("quality preserves commands and routes each domain independently after fail
"npx --yes lockfile-lint --path package-lock.json --type npm --allowed-hosts npm --validate-https --validate-integrity",
"npm audit signatures",
"npm run lint",
"node --test scripts/oxlint/paseo-plugin.test.mjs",
],
relay: ["npm run build:relay", "npm run test --workspace=@getpaseo/relay"],
sdk: [
+42
View File
@@ -0,0 +1,42 @@
// Repo-specific oxlint rules. Each rule is its own name, so its burn-down list in .oxlintrc.json
// is one override that cannot interact with the `no-restricted-imports` burn-downs.
const PRESS_PRIMITIVE_SOURCES = new Set(["react-native", "react-native-gesture-handler"]);
const PRESS_PRIMITIVE_NAMES = new Set([
"Pressable",
"TouchableHighlight",
"TouchableNativeFeedback",
"TouchableOpacity",
"TouchableWithoutFeedback",
]);
export const NO_RAW_PRESSABLE_MESSAGE =
"A raw pressable has no standard press or hover feedback. Icon actions use ToolbarButton " +
"(@/components/ui/pane-content-toolbar); rows on a menu surface use MenuItem or " +
"ComposerTrackRow; other rows use PressHighlight (@/components/ui/press-highlight); labelled " +
"buttons use Button. See docs/design.md §2.";
const noRawPressable = {
create(context) {
return {
ImportDeclaration(node) {
if (!PRESS_PRIMITIVE_SOURCES.has(node.source.value)) return;
for (const specifier of node.specifiers) {
if (
specifier.type === "ImportSpecifier" &&
PRESS_PRIMITIVE_NAMES.has(specifier.imported.name)
) {
context.report({ node: specifier, message: NO_RAW_PRESSABLE_MESSAGE });
}
}
},
};
},
};
export default {
meta: { name: "paseo" },
rules: {
"no-raw-pressable": noRawPressable,
},
};
+54
View File
@@ -0,0 +1,54 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const oxlintBin = path.join(repoRoot, "node_modules/.bin/oxlint");
const pluginPath = path.join(repoRoot, "scripts/oxlint/paseo-plugin.mjs");
function lint(source) {
const dir = mkdtempSync(path.join(tmpdir(), "paseo-oxlint-"));
try {
writeFileSync(
path.join(dir, ".oxlintrc.json"),
JSON.stringify({ jsPlugins: [pluginPath], rules: { "paseo/no-raw-pressable": "error" } }),
);
writeFileSync(path.join(dir, "probe.tsx"), source);
const result = spawnSync(oxlintBin, ["-c", ".oxlintrc.json", "probe.tsx"], {
cwd: dir,
encoding: "utf8",
});
return { failed: result.status !== 0, output: result.stdout + result.stderr };
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
test("a raw Pressable from react-native is reported", () => {
const result = lint(
'import { Pressable, View } from "react-native";\nexport { Pressable, View };\n',
);
assert.equal(result.failed, true);
assert.match(result.output, /no-raw-pressable/);
});
test("an aliased gesture-handler touchable is reported", () => {
const result = lint(
'import { TouchableOpacity as Tap } from "react-native-gesture-handler";\nexport { Tap };\n',
);
assert.equal(result.failed, true);
assert.match(result.output, /no-raw-pressable/);
});
test("press primitives and pressable types are allowed", () => {
const result = lint(
'import { View, type PressableProps } from "react-native";\n' +
'import { ToolbarButton } from "@/components/ui/pane-content-toolbar";\n' +
"export type Props = PressableProps;\nexport { View, ToolbarButton };\n",
);
assert.equal(result.failed, false, result.output);
});