Skip to content
Closed
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
104 changes: 100 additions & 4 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ import type { MenuAction } from "@react-native-menu/menu";
import { memo, useCallback, useEffect, useMemo, type ComponentProps } from "react";
import { Platform, Pressable, useWindowDimensions, View } from "react-native";
import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
import Animated, {
cancelAnimation,
Easing,
ReduceMotion,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
type SharedValue,
} from "react-native-reanimated";

import { AppText as Text } from "../../components/AppText";
import { ControlPillMenu } from "../../components/ControlPill";
Expand Down Expand Up @@ -38,6 +48,93 @@ const STATUS_LABEL_BY_STATUS: Partial<
failed: { label: "Failed", className: "text-red-700 dark:text-red-300" },
};

const WORKING_WAVE_CYCLE_MS = 4000;
const WORKING_WAVE_STAGGER_MS = 60;
const WORKING_WAVE_OFFSET = 1.1;
const WORKING_WAVE_EASING = Easing.bezierFn(0.42, 0, 0.58, 1);

function WorkingStatusLetter(props: {
readonly letter: string;
readonly index: number;
readonly progress: SharedValue<number>;
readonly className: string;
}) {
const style = useAnimatedStyle(() => {
const phase =
(props.progress.value - (props.index * WORKING_WAVE_STAGGER_MS) / WORKING_WAVE_CYCLE_MS + 1) %
1;
let translateY = 0;

if (phase < 0.04) {
translateY = -WORKING_WAVE_OFFSET * WORKING_WAVE_EASING(phase / 0.04);
} else if (phase < 0.08) {
translateY =
-WORKING_WAVE_OFFSET + WORKING_WAVE_OFFSET * 2 * WORKING_WAVE_EASING((phase - 0.04) / 0.04);
} else if (phase < 0.12) {
translateY = WORKING_WAVE_OFFSET * (1 - WORKING_WAVE_EASING((phase - 0.08) / 0.04));
}

return { transform: [{ translateY }] };
});

return (
<Animated.View style={style}>
<Text className={props.className}>{props.letter}</Text>
</Animated.View>
);
}

function WorkingStatusText({ className }: { readonly className: string }) {
const progress = useSharedValue(0);

useEffect(() => {
progress.value = 0;
progress.value = withRepeat(
withTiming(1, {
duration: WORKING_WAVE_CYCLE_MS,
easing: Easing.linear,
reduceMotion: ReduceMotion.System,
}),
-1,
false,
undefined,
ReduceMotion.System,
);

return () => {
cancelAnimation(progress);
};
}, [progress]);

return (
<View accessible accessibilityLabel="Working" className="flex-row items-center">
{Array.from("Working", (letter, index) => (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nested accessible status inside button

Medium Severity

WorkingStatusText marks its container accessible with accessibilityLabel="Working" inside a card Pressable that already exposes accessibilityLabel={thread.title} and accessibilityRole="button". That adds a separate assistive-tech focus stop nested in the row button, unlike other statuses rendered as plain Text, and can split navigation away from the thread action.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a26d17d. Configure here.

<WorkingStatusLetter
key={`${letter}-${index}`}
letter={letter}
index={index}
progress={progress}
className={className}
/>
))}
</View>
);
}

function ThreadListV2StatusText({
label,
className,
}: {
readonly label: string;
readonly className: string;
}) {
if (label === "Working") {
return <WorkingStatusText className={className} />;
}

return <Text className={className}>{label}</Text>;
}

function threadTimeLabel(thread: EnvironmentThreadShell): string {
return relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt);
}
Expand Down Expand Up @@ -212,14 +309,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
>
{props.project?.title ?? ""}
</Text>
<Text
<ThreadListV2StatusText
label={statusLabel?.label ?? timeLabel}
className={cn(
"text-xs tabular-nums",
statusLabel?.className ?? "text-foreground-tertiary",
)}
>
{statusLabel?.label ?? timeLabel}
</Text>
/>
</View>
<Text className="mt-1 text-base font-t3-medium text-foreground" numberOfLines={2}>
{thread.title}
Expand Down
13 changes: 8 additions & 5 deletions apps/web/src/components/SidebarV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ import {
resolveSidebarV2Status,
sortThreadsForSidebarV2,
} from "./Sidebar.logic";
import { prStatusIndicator, resolveThreadPr } from "./ThreadStatusIndicators";
import { prStatusIndicator, resolveThreadPr, ThreadStatusText } from "./ThreadStatusIndicators";
import { ProjectFavicon } from "./ProjectFavicon";
import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";
import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances";
Expand Down Expand Up @@ -185,8 +185,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
? {
label: "Working",
icon: "working" as const,
className:
"animate-sidebar-working-text font-semibold text-blue-600 motion-reduce:animate-none dark:text-blue-400",
className: "font-semibold text-blue-600 dark:text-blue-400",
}
: status === "approval"
? {
Expand Down Expand Up @@ -518,17 +517,21 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
) : topStatus ? (
<span
role="status"
aria-label={topStatus.label}
className={cn(
"inline-flex items-center gap-1 text-[11px]",
topStatus.className,
)}
>
{topStatus.icon === "working" ? (
<CircleDashedIcon aria-hidden className="size-3" />
<CircleDashedIcon
aria-hidden
className="size-3 animate-spin motion-reduce:animate-none"
/>
) : topStatus.icon === "done" ? (
<CircleCheckIcon aria-hidden className="size-3" />
) : null}
{topStatus.label}
<ThreadStatusText label={topStatus.label} className="inline whitespace-pre" />
</span>
) : (
threadTimeLabel(thread)
Expand Down
51 changes: 50 additions & 1 deletion apps/web/src/components/ThreadStatusIndicators.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,56 @@ import { ThreadId } from "@t3tools/contracts";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vite-plus/test";

import { ThreadWorktreeIndicator } from "./ThreadStatusIndicators";
import {
ThreadStatusLabel,
ThreadStatusText,
ThreadWorktreeIndicator,
} from "./ThreadStatusIndicators";

describe("ThreadStatusLabel", () => {
it("renders the Working label as an accessible staggered wave", () => {
const markup = renderToStaticMarkup(
<ThreadStatusLabel
status={{
label: "Working",
colorClass: "text-sky-600",
dotClass: "bg-sky-500",
pulse: true,
}}
/>,
);

expect(markup).toContain('aria-label="Working"');
expect(markup).toContain('aria-hidden="true"');
expect(markup.match(/animate-working-wave/g)).toHaveLength("Working".length);
expect(markup).toContain("animation-delay:360ms");
});

it("leaves non-working status text static", () => {
const markup = renderToStaticMarkup(
<ThreadStatusLabel
status={{
label: "Connecting",
colorClass: "text-sky-600",
dotClass: "bg-sky-500",
pulse: true,
}}
/>,
);

expect(markup).toContain("Connecting");
expect(markup).not.toContain("animate-working-wave");
});

it("renders sidebar-specific status copy without animation", () => {
const markup = renderToStaticMarkup(
<ThreadStatusText label="Failed" className="inline whitespace-pre" />,
);

expect(markup).toContain("Failed");
expect(markup).not.toContain("animate-working-wave");
});
});

describe("ThreadWorktreeIndicator", () => {
it("renders the worktree folder and branch in an accessible label", () => {
Expand Down
28 changes: 27 additions & 1 deletion apps/web/src/components/ThreadStatusIndicators.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,13 +173,39 @@ export function ThreadStatusLabel({
status.pulse ? "animate-status-pulse" : ""
}`}
/>
<span className="hidden md:inline">{status.label}</span>
<ThreadStatusText label={status.label} />
</TooltipTrigger>
<TooltipPopup side="top">{status.label}</TooltipPopup>
</Tooltip>
);
}

export function ThreadStatusText({
label,
className = "hidden md:inline whitespace-pre",
}: {
label: string;
className?: string;
}) {
if (label !== "Working") {
return <span className={className}>{label}</span>;
}

return (
<span aria-hidden="true" className={className}>
{Array.from(label, (letter, index) => (
<span
key={`${letter}-${index}`}
className="inline-block animate-working-wave motion-reduce:animate-none"
style={{ animationDelay: `${index * 60}ms` }}
>
{letter}
</span>
))}
</span>
);
}

/**
* Non-interactive leading status icons for a thread row in compact contexts
* like the command palette. Shows the change request state icon (if present) and the
Expand Down
27 changes: 15 additions & 12 deletions apps/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ html[data-mobile-composer-route-transition="true"]::view-transition-new(t3-mobil
}
}

html[data-mobile-composer-route-transition="true"]::view-transition-group(t3-mobile-draft-headline) {
html[data-mobile-composer-route-transition="true"]::view-transition-group(
t3-mobile-draft-headline
) {
animation-duration: 130ms;
}

Expand Down Expand Up @@ -112,7 +114,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
compositor updates discrete frames instead of every vsync. */
--animate-status-pulse: status-pulse 2s infinite;
--animate-status-ping: status-ping 2s infinite;
--animate-sidebar-working-text: sidebar-working-text 3.4s infinite;
--animate-working-wave: working-wave 4s ease-in-out infinite;
--font-sans:
"DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,
sans-serif;
Expand Down Expand Up @@ -185,19 +187,20 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
scale: 2;
}
}
@keyframes sidebar-working-text {
@keyframes working-wave {
0%,
36% {
opacity: 1;
animation-timing-function: steps(10);
15%,
100% {
transform: translateY(0);
}
50%,
86% {
opacity: 0.75;
animation-timing-function: steps(10);
4% {
transform: translateY(-0.1em);
}
100% {
opacity: 1;
8% {
transform: translateY(0.1em);
}
12% {
transform: translateY(0);
}
}
}
Expand Down
Loading