Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 79 additions & 74 deletions src/components/extensions/extension-filters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,10 @@ export function ExtensionFilters() {

return (
<div className="space-y-2.5">
{/* Filters: kind pills + result count + dropdowns + search */}
<div className="flex items-center gap-2">
{/* Filters: kind pills + result count + dropdowns + search. Wraps on
narrow windows: the bar sits above the scroll container, so anything
past the right edge would otherwise be clipped with no way to reach it. */}
<div className="flex flex-wrap items-center gap-2">
{kinds.map((kind) => (
<button
key={kind ?? "all"}
Expand Down Expand Up @@ -214,83 +216,86 @@ export function ExtensionFilters() {
{t("filters.clearFilters")}
</button>
)}
<div className="flex-1" />
{enabledAgents.length > 0 && (
<select
value={agentFilter ?? ""}
onChange={(e) => setAgentFilter(e.target.value || null)}
aria-label={t("filters.filterByAgent")}
style={webSelectStyle}
className={clsx(
"shrink-0 border px-3 text-xs capitalize focus:outline-none transition-colors",
web ? "rounded-[6px] h-[26px]" : "rounded-lg py-1.5",
agentFilter && AGENT_FILTER_COLORS[agentFilter]
? `${AGENT_FILTER_COLORS[agentFilter]}${web ? " font-medium" : ""}`
: "border-border bg-card text-foreground focus:border-ring",
)}
>
<option value="">{t("filters.allAgents")}</option>
{enabledAgents.map((agent) => (
<option key={agent.name} value={agent.name}>
{agentDisplayName(agent.name)}
</option>
))}
</select>
)}
{/* Built-in packs live in their own group, so a scope holding ONLY
{/* Dropdowns + search stay right-aligned, on their own line when the
pills leave no room. */}
<div className="ml-auto flex flex-wrap items-center gap-2">
{enabledAgents.length > 0 && (
<select
value={agentFilter ?? ""}
onChange={(e) => setAgentFilter(e.target.value || null)}
aria-label={t("filters.filterByAgent")}
style={webSelectStyle}
className={clsx(
"shrink-0 border px-3 text-xs capitalize focus:outline-none transition-colors",
web ? "rounded-[6px] h-[26px]" : "rounded-lg py-1.5",
agentFilter && AGENT_FILTER_COLORS[agentFilter]
? `${AGENT_FILTER_COLORS[agentFilter]}${web ? " font-medium" : ""}`
: "border-border bg-card text-foreground focus:border-ring",
)}
>
<option value="">{t("filters.allAgents")}</option>
{enabledAgents.map((agent) => (
<option key={agent.name} value={agent.name}>
{agentDisplayName(agent.name)}
</option>
))}
</select>
)}
{/* Built-in packs live in their own group, so a scope holding ONLY
those still needs the dropdown — otherwise its one option is
unreachable, and a built-in selection made elsewhere would stay
active with no visible control. */}
{(scopedPacks.length > 0 || builtinCounts.size > 0) && (
<select
value={packFilter ?? ""}
onChange={(e) => setPackFilter(e.target.value || null)}
aria-label={t("filters.filterBySource")}
style={webSelectStyle}
className={clsx(
"w-36 shrink-0 overflow-hidden text-ellipsis border border-border bg-card px-3 text-xs text-foreground focus:border-ring focus:outline-none",
web ? "rounded-[6px] h-[26px]" : "rounded-lg py-1.5",
)}
>
<option value="">{t("filters.allSources")}</option>
{[...builtinCounts].map(([agent, count]) => (
<option key={agent} value={`${BUILTIN_PACK_PREFIX}${agent}`}>
{t("filters.builtinSource", {
agent: agentDisplayName(agent),
count,
})}
</option>
))}
{scopedPacks.map((pack) => (
<option key={pack} value={pack}>
{pack} ({packCounts.get(pack) ?? 0})
</option>
))}
</select>
)}
<div className="relative shrink-0 w-44">
<Search
size={14}
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t("filters.searchPlaceholder")}
title={t("filters.searchTitle")}
aria-label={t("filters.searchAria")}
className="w-full rounded-lg border border-border bg-card py-1.5 pl-8 pr-8 text-xs placeholder:text-muted-foreground focus:border-ring focus:outline-none"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery("")}
aria-label={t("filters.clearSearch")}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
{(scopedPacks.length > 0 || builtinCounts.size > 0) && (
<select
value={packFilter ?? ""}
onChange={(e) => setPackFilter(e.target.value || null)}
aria-label={t("filters.filterBySource")}
style={webSelectStyle}
className={clsx(
"w-36 shrink-0 overflow-hidden text-ellipsis border border-border bg-card px-3 text-xs text-foreground focus:border-ring focus:outline-none",
web ? "rounded-[6px] h-[26px]" : "rounded-lg py-1.5",
)}
>
<X size={14} />
</button>
<option value="">{t("filters.allSources")}</option>
{[...builtinCounts].map(([agent, count]) => (
<option key={agent} value={`${BUILTIN_PACK_PREFIX}${agent}`}>
{t("filters.builtinSource", {
agent: agentDisplayName(agent),
count,
})}
</option>
))}
{scopedPacks.map((pack) => (
<option key={pack} value={pack}>
{pack} ({packCounts.get(pack) ?? 0})
</option>
))}
</select>
)}
<div className="relative shrink-0 w-44">
<Search
size={14}
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t("filters.searchPlaceholder")}
title={t("filters.searchTitle")}
aria-label={t("filters.searchAria")}
className="w-full rounded-lg border border-border bg-card py-1.5 pl-8 pr-8 text-xs placeholder:text-muted-foreground focus:border-ring focus:outline-none"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery("")}
aria-label={t("filters.clearSearch")}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X size={14} />
</button>
)}
</div>
</div>
</div>
</div>
Expand Down
46 changes: 46 additions & 0 deletions src/components/layout/__tests__/update-dialogs.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it } from "vitest";
import { useUpdateStore } from "@/stores/update-store";
import { useWebUpdateStore } from "@/stores/web-update-store";
import { UpdateDialog } from "../update-dialog";
import { WebUpdateDialog } from "../web-update-dialog";

const available = { version: "9.9.9", body: "notes" };

describe.each([
{
name: "UpdateDialog",
Dialog: UpdateDialog,
open: () => useUpdateStore.setState({ showChangelog: true, available }),
isOpen: () => useUpdateStore.getState().showChangelog,
},
{
name: "WebUpdateDialog",
Dialog: WebUpdateDialog,
open: () => useWebUpdateStore.setState({ showDialog: true, available }),
isOpen: () => useWebUpdateStore.getState().showDialog,
},
])("$name keyboard handling", ({ Dialog, open, isOpen }) => {
beforeEach(() => open());

it("moves focus into the dialog and keeps Tab cycling inside it", async () => {
const user = userEvent.setup();
render(<Dialog />);
const dialog = screen.getByRole("dialog");
expect(dialog.contains(document.activeElement)).toBe(true);

const focusable = dialog.querySelectorAll<HTMLElement>("button, a[href]");
focusable[focusable.length - 1].focus();
await user.tab();
expect(document.activeElement).toBe(focusable[0]);
});

it("closes on Escape", async () => {
const user = userEvent.setup();
render(<Dialog />);
await user.keyboard("{Escape}");
expect(isOpen()).toBe(false);
expect(screen.queryByRole("dialog")).toBeNull();
});
});
11 changes: 10 additions & 1 deletion src/components/layout/update-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { Download, Loader2, X } from "lucide-react";
import { useRef } from "react";
import { useTranslation } from "react-i18next";
import { useEscape } from "@/hooks/use-escape";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { localizeChangelog } from "@/lib/i18n/changelog";
import { useUpdateStore } from "@/stores/update-store";
import { ChangelogMarkdown } from "./changelog-markdown";
Expand All @@ -13,7 +16,12 @@ export function UpdateDialog() {
const dismissUpdate = useUpdateStore((s) => s.dismissUpdate);
const confirmUpdate = useUpdateStore((s) => s.confirmUpdate);

if (!showChangelog || !available) return null;
const open = showChangelog && available !== null;
const dlgRef = useRef<HTMLDivElement>(null);
useFocusTrap(dlgRef, open);
useEscape(dismissDialog, open);

if (!open) return null;

return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
Expand All @@ -24,6 +32,7 @@ export function UpdateDialog() {

{/* Dialog */}
<div
ref={dlgRef}
role="dialog"
aria-modal="true"
className="relative w-[420px] max-h-[70vh] flex flex-col rounded-xl border border-border bg-background shadow-xl"
Expand Down
11 changes: 10 additions & 1 deletion src/components/layout/web-update-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { ExternalLink, X } from "lucide-react";
import { useRef } from "react";
import { useTranslation } from "react-i18next";
import { useEscape } from "@/hooks/use-escape";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { localizeChangelog } from "@/lib/i18n/changelog";
import { useWebUpdateStore } from "@/stores/web-update-store";
import { ChangelogMarkdown } from "./changelog-markdown";
Expand All @@ -13,7 +16,12 @@ export function WebUpdateDialog() {
const dismissDialog = useWebUpdateStore((s) => s.dismissDialog);
const dismissUpdate = useWebUpdateStore((s) => s.dismissUpdate);

if (!showDialog || !available) return null;
const open = showDialog && available !== null;
const dlgRef = useRef<HTMLDivElement>(null);
useFocusTrap(dlgRef, open);
useEscape(dismissDialog, open);

if (!open) return null;

return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
Expand All @@ -23,6 +31,7 @@ export function WebUpdateDialog() {
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" />

<div
ref={dlgRef}
role="dialog"
aria-modal="true"
className="relative w-[420px] max-h-[70vh] flex flex-col rounded-xl border border-border bg-background shadow-xl"
Expand Down
36 changes: 33 additions & 3 deletions src/components/onboarding/onboarding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,27 @@ const SCATTER_POSITIONS: Record<
pi: { x: -80, y: 160, r: -18 },
};

const MASCOT_SIZE = 44;
const MASCOT_GAP = 10;
const MASCOT_ROW_GAP = 14;
// Breathing room the single-row layout must keep on each side of the viewport.
const ROW_SIDE_MARGIN = 40;

function mascotRowWidth(count: number): number {
return count * MASCOT_SIZE + (count - 1) * MASCOT_GAP;
}

// The agent row stays on one line while the viewport is wide enough for it
// plus the side margins; narrower than that it splits into two even rows
// (ceil(n/2) + floor(n/2)) so the second row never holds a couple of orphans.
// `clamp()` does the switch without a resize listener: the middle term is
// hugely positive above the threshold (clamped to the full width) and hugely
// negative below it (clamped to the half width).
const FULL_ROW_WIDTH = mascotRowWidth(AGENT_ORDER.length);
const HALF_ROW_WIDTH = mascotRowWidth(Math.ceil(AGENT_ORDER.length / 2));
const SINGLE_ROW_MIN_VIEWPORT = FULL_ROW_WIDTH + 2 * ROW_SIDE_MARGIN;
const AGENT_ROW_WIDTH = `clamp(${HALF_ROW_WIDTH}px, (100vw - ${SINGLE_ROW_MIN_VIEWPORT}px) * 9999, ${FULL_ROW_WIDTH}px)`;

function HandAnnotation({
type,
delay = 2400,
Expand Down Expand Up @@ -348,8 +369,17 @@ function StepWelcome() {

return (
<div className="flex flex-col items-center text-center">
<div className="relative mb-12">
<div className="relative flex items-center justify-center gap-2.5">
{/* The step column is narrower than the full row; `justify-center` on
the wrapper lets the fixed-width row overflow it evenly on both sides. */}
<div className="relative mb-12 flex justify-center">
<div
className="relative flex shrink-0 flex-wrap items-center justify-center"
style={{
width: AGENT_ROW_WIDTH,
columnGap: MASCOT_GAP,
rowGap: MASCOT_ROW_GAP,
}}
>
{AGENT_ORDER.map((name, i) => {
const s = SCATTER_POSITIONS[name];
return (
Expand All @@ -366,7 +396,7 @@ function StepWelcome() {
: "none",
}}
>
<AgentMascot name={name} size={44} />
<AgentMascot name={name} size={MASCOT_SIZE} />
</div>
);
})}
Expand Down
9 changes: 6 additions & 3 deletions src/components/shared/agent-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const CLICK_DURATIONS: Partial<Record<AgentInfo["name"], number>> = {
openclaw: 3050,
// shake and wake scatter share one 1.6s track
penguin: 1650,
// slide together, hold, puff away, pause and fade back: 3s plus the last leg's delay
// the tetris replay is one 3s clock: four pieces, flash, settle, pick-up
pi: 3200,
};

Expand All @@ -47,12 +47,15 @@ export function AgentCard({ agent }: AgentCardProps) {
>
{/* dsh dives below its box on hover; clip around the mascot (with
top headroom for the leap and the spout) so the whale vanishes
above the label instead of sliding over the text */}
above the label instead of sliding over the text. pi's tetris
pieces fall in from the card's top edge, so clip there. */}
<div
className={
agent.name === "dsh"
? "-mx-3 -mt-4 overflow-hidden px-3 pt-4"
: undefined
: agent.name === "pi"
? "-mx-3 -mt-2.5 overflow-hidden px-3 pt-2.5"
: undefined
}
>
<AgentMascot
Expand Down
Loading
Loading