Skip to content
Closed
45 changes: 45 additions & 0 deletions apps/mobile/src/components/ProgressRing.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import Svg, { Circle } from "react-native-svg";
import { withUniwind } from "uniwind";

const ThemedCircle = withUniwind(Circle);

const RADIUS = 8;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;

/** Fills clockwise with `fraction` in the color `colorClassName` resolves to. */
export function ProgressRing(props: {
readonly fraction: number;
readonly size: number;
readonly colorClassName: string;
}) {
const filled = Math.min(1, Math.max(0, props.fraction));
return (
<Svg width={props.size} height={props.size} viewBox="0 0 20 20" accessible={false}>
<ThemedCircle
cx={10}
cy={10}
r={RADIUS}
fill="none"
stroke="currentColor"
strokeOpacity={0.2}
strokeWidth={3}
colorClassName={props.colorClassName}
/>
{filled > 0 ? (
<ThemedCircle
cx={10}
cy={10}
r={RADIUS}
fill="none"
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
strokeDasharray={`${CIRCUMFERENCE * filled} ${CIRCUMFERENCE}`}
rotation={-90}
origin="10, 10"
colorClassName={props.colorClassName}
/>
) : null}
</Svg>
);
}
41 changes: 41 additions & 0 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,9 @@ import { useThreadTurnSubagents } from "./ThreadAgentsSheet";
import { ComposerQueuedEditBanner } from "./ComposerQueuedEdit";
import { useThreadQueuedCount } from "./ThreadQueueControl";
import { ThreadGuestShareBar } from "./ThreadGuestShareBar";
import { ThreadProgressSheet } from "./ThreadProgressSheet";
import { presentThreadProgress } from "./thread-progress-presentation";
import { useThreadProgress } from "./use-thread-progress";
import type { ThreadContentPresentation } from "./threadContentPresentation";
import { resolveThreadFeedSubmissionAnchor } from "./thread-feed-live-follow";
import { useOpenLinkedThread } from "./useOpenLinkedThread";
Expand Down Expand Up @@ -609,6 +612,35 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) {
const pendingBackgroundWork = presentPendingBackgroundWork(
props.selectedThread.pendingBackgroundTasks,
);
const [progressSheetOpen, setProgressSheetOpen] = useState(false);
Comment thread
Kaloyanes marked this conversation as resolved.
Resolved
const openProgressSheet = () => {
Keyboard.dismiss();
setProgressSheetOpen(true);
};
const threadProgress = useThreadProgress({
threadRef: props.selectedThreadDetailRef,
thread: props.selectedThread,
hasPendingApproval: props.activePendingApproval !== null,
hasPendingUserInput: props.activePendingUserInput !== null,
});
if (threadProgress === null && progressSheetOpen) setProgressSheetOpen(false);
const progressStatus = ((): FloatingWorkingStatus | null => {
if (threadProgress === null || contentPresentationKind !== "ready") return null;
const progress = presentThreadProgress(threadProgress.steps, threadProgress.state);
const blocked = progress.note !== null;
if (props.activeWorkStartedAt === null && !blocked) return null;
return {
kind: "progress",
startedAt: props.activeWorkStartedAt,
title: progress.title,
count: progress.count,
waitingNote: blocked ? "Waiting" : null,
fraction: progress.fraction,
tone: progress.tone,
accessibilityLabel: `${progress.title}, ${progress.description}. Shows every step`,
onPress: openProgressSheet,
};
})();
const floatingStatus = ((): FloatingWorkingStatus | null => {
const connectionStatus = connectionFloatingStatus({
connectionError: props.connectionError,
Expand Down Expand Up @@ -650,6 +682,7 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) {
if (props.isCompacting && contentPresentationKind === "ready") {
return { kind: "compacting" };
}
if (progressStatus !== null) return progressStatus;
if (props.activeWorkStartedAt !== null && contentPresentationKind === "ready") {
return { kind: "working", startedAt: props.activeWorkStartedAt };
}
Expand Down Expand Up @@ -1383,6 +1416,14 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) {
className="w-full"
>
<View className="w-full self-center" style={{ maxWidth: contentMaxWidth }}>
{threadProgress !== null ? (
<ThreadProgressSheet
open={progressSheetOpen}
onClose={() => setProgressSheetOpen(false)}
steps={threadProgress.steps}
state={threadProgress.state}
/>
) : null}
<FloatingWorkingControl
colorScheme={isDarkMode ? "dark" : "light"}
status={floatingStatus}
Expand Down
158 changes: 158 additions & 0 deletions apps/mobile/src/features/threads/ThreadProgressSheet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import type { OrchestrationV2PlanStep } from "@t3tools/contracts";
import type { TodoRunState } from "@t3tools/client-runtime/state/todo-progress";
import BottomSheet, { BottomSheetView } from "@expo/ui/community/bottom-sheet";
import { ScrollView, StyleSheet, useWindowDimensions, View } from "react-native";
import { useResolveClassNames } from "uniwind";

import { AppText as Text } from "../../components/ui/AppText";
import { SymbolView } from "../../components/ui/AppSymbol";
import { cn } from "../../lib/cn";
import {
presentThreadProgress,
presentThreadProgressStep,
THREAD_PROGRESS_TONE_CLASSES,
type ThreadProgressTone,
} from "./thread-progress-presentation";

const SEGMENT_TRACK_CLASSES = {
active: "bg-adaptive-sky-600-400/20",
waiting: "bg-warning-foreground/20",
done: "bg-adaptive-emerald-700-300/20",
muted: "bg-foreground-muted/20",
} as const satisfies Record<ThreadProgressTone, string>;

const SEGMENT_FILL_CLASSES = {
active: "bg-adaptive-sky-600-400",
waiting: "bg-warning-foreground",
done: "bg-adaptive-emerald-700-300",
muted: "bg-foreground-muted",
} as const satisfies Record<ThreadProgressTone, string>;

const ROW_TINT_CLASSES = {
active: "bg-adaptive-sky-600-400/10",
waiting: "bg-warning-foreground/10",
done: "",
muted: "",
} as const satisfies Record<ThreadProgressTone, string>;

function ProgressSegments(props: {
readonly segments: ReadonlyArray<{
readonly id: string;
readonly status: OrchestrationV2PlanStep["status"];
}> | null;
readonly fraction: number;
readonly tone: ThreadProgressTone;
}) {
const track = SEGMENT_TRACK_CLASSES[props.tone];
const fill = SEGMENT_FILL_CLASSES[props.tone];
if (props.segments === null) {
return (
<View className={cn("h-1.5 overflow-hidden rounded-full", track)}>
<View
className={cn("h-full rounded-full", fill)}
style={{ width: `${Math.round(props.fraction * 100)}%` }}
/>
</View>
);
}
return (
<View className="flex-row gap-1">
{props.segments.map(({ id, status }) => (
<View key={id} className={cn("h-1.5 flex-1 overflow-hidden rounded-full", track)}>
{status === "completed" ? <View className={cn("h-full w-full", fill)} /> : null}
{status === "running" ? <View className={cn("h-full w-[45%]", fill)} /> : null}
</View>
))}
</View>
);
}

export function ThreadProgressSheet({
open,
onClose,
steps,
state,
}: {
readonly open: boolean;
readonly onClose: () => void;
readonly steps: ReadonlyArray<OrchestrationV2PlanStep>;
readonly state: TodoRunState;
}) {
const { height: windowHeight } = useWindowDimensions();
const progress = presentThreadProgress(steps, state);
const background = StyleSheet.flatten(useResolveClassNames("bg-screen"));
return (
<BottomSheet
index={open ? 0 : -1}
onClose={onClose}
enablePanDownToClose
backgroundStyle={background}
>
<BottomSheetView>
<View className="px-5 pb-2">
<View className="flex-row items-baseline justify-between">
<Text accessibilityRole="header" className="text-2xl font-t3-bold text-foreground">
Progress
</Text>
<Text className="text-sm tabular-nums text-foreground-muted">{progress.summary}</Text>
</View>
<View className="mt-3">
<ProgressSegments
segments={progress.segments}
fraction={progress.fraction}
tone={progress.tone}
/>
</View>
</View>
<ScrollView
style={{ maxHeight: windowHeight * 0.55 }}
contentContainerStyle={{ paddingHorizontal: 12, paddingBottom: 36, paddingTop: 6 }}
>
{steps.map((step) => {
const row = presentThreadProgressStep(step, state);
const tone = THREAD_PROGRESS_TONE_CLASSES[row.tone];
return (
<View
key={step.id}
accessibilityState={{ selected: step.status === "running" }}
className={cn(
"flex-row items-center gap-3 rounded-2xl px-3 py-3",
row.highlight && ROW_TINT_CLASSES[row.tone],
)}
>
<SymbolView
name={row.icon}
size={22}
tintColorClassName={tone.accent}
type="monochrome"
weight="medium"
/>
<View className="min-w-0 flex-1">
<Text
className={cn(
"text-base",
row.highlight ? "font-t3-bold text-foreground" : "text-foreground-muted",
row.strike && "line-through",
)}
>
{step.text}
</Text>
{row.note ? (
<Text
className={cn(
"mt-0.5 text-xs font-t3-medium",
row.tone === "waiting" ? tone.text : "text-foreground-muted",
)}
>
{row.note}
</Text>
) : null}
</View>
</View>
);
})}
</ScrollView>
</BottomSheetView>
</BottomSheet>
);
}
61 changes: 60 additions & 1 deletion apps/mobile/src/features/threads/floating-working-control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,12 @@ import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator";
import { AppText as Text } from "../../components/ui/AppText";
import { SymbolView } from "../../components/ui/AppSymbol";
import { ControlPill } from "../../components/ui/ControlPill";
import { ProgressRing } from "../../components/ProgressRing";
import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
import { BrowserPreviewButton } from "../browser/browser-preview-button";
import { DevicePreviewButton } from "../devices/device-preview-button";
import type { FloatingWorkingStatus } from "./floating-working-status";
import { THREAD_PROGRESS_TONE_CLASSES } from "./thread-progress-presentation";
import { ShimmeringWorkContent } from "./thread-work-log";
import { useVisibleSecondClock } from "./use-visible-second-clock";

Expand Down Expand Up @@ -160,6 +162,7 @@ export function FloatingWorkingControl(props: {
const statusInteractive =
props.status?.kind === "connection" ||
props.status?.kind === "child-input" ||
props.status?.kind === "progress" ||
props.status?.kind === "background";
const capsuleInteractive =
statusInteractive || action !== null || hasQueue || hasAgents || hasPreview;
Expand All @@ -177,7 +180,9 @@ export function FloatingWorkingControl(props: {
>
<FloatingStatusLabel
key={
props.status.kind === "working" || props.status.kind === "compacting"
props.status.kind === "working" ||
props.status.kind === "compacting" ||
props.status.kind === "progress"
? props.status.kind
: `${props.status.kind}:${props.status.label}`
}
Expand Down Expand Up @@ -486,6 +491,40 @@ function FloatingStatusLabel(props: {
</StatusLabelRow>
);
}
if (props.status.kind === "progress") {
return (
<StatusLabelRow
key="progress"
accessibilityLabel={props.status.accessibilityLabel}
accessibilityRole="button"
className="gap-2.5"
onLayout={props.onLayout}
onPress={props.status.onPress}
>
<ProgressRing
fraction={props.status.fraction}
size={18}
colorClassName={THREAD_PROGRESS_TONE_CLASSES[props.status.tone].accent}
/>
<Text className="shrink font-t3-bold text-sm text-foreground" numberOfLines={1}>
{props.status.title}
</Text>
<View className="h-3.5 w-px bg-border" />
<ProgressMeta
count={props.status.count}
startedAt={props.status.startedAt}
waitingNote={props.status.waitingNote}
/>
<SymbolView
name="chevron.up"
size={11}
tintColorClassName="accent-icon-muted"
type="monochrome"
weight="semibold"
/>
</StatusLabelRow>
);
}
if (props.status.kind === "preparing") {
return (
<StatusLabelRow
Expand Down Expand Up @@ -562,6 +601,26 @@ function WorkingDuration(props: {
);
}

function ProgressMeta(props: {
readonly count: string;
readonly startedAt: string | null;
readonly waitingNote: string | null;
}) {
const nowMs = useVisibleSecondClock(props.startedAt !== null && props.waitingNote === null);
const trailing =
props.waitingNote ??
(props.startedAt === null ? null : formatWorkingDuration(props.startedAt, nowMs));
return (
<SystemText
className="shrink-0 text-xs text-foreground-muted"
numberOfLines={1}
style={{ fontVariant: ["tabular-nums"], fontWeight: "500" }}
>
{trailing === null ? props.count : `${props.count} · ${trailing}`}
</SystemText>
);
}

function WorkingTimer(props: { readonly startedAt: string }) {
const nowMs = useVisibleSecondClock(true);
return (
Expand Down
13 changes: 13 additions & 0 deletions apps/mobile/src/features/threads/floating-working-status.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";

import type { ThreadProgressTone } from "./thread-progress-presentation";

export interface FloatingStatusAction {
readonly label: string;
readonly accessibilityLabel: string;
Expand All @@ -9,6 +11,17 @@ export interface FloatingStatusAction {

export type FloatingWorkingStatus =
| { readonly kind: "working"; readonly startedAt: string }
| {
readonly kind: "progress";
readonly startedAt: string | null;
readonly title: string;
readonly count: string;
readonly waitingNote: string | null;
readonly fraction: number;
readonly tone: ThreadProgressTone;
readonly accessibilityLabel: string;
readonly onPress: () => void;
}
| { readonly kind: "syncing"; readonly label: string }
| { readonly kind: "compacting" }
| {
Expand Down
Loading
Loading