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
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>
);
}
40 changes: 40 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,34 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) {
const pendingBackgroundWork = presentPendingBackgroundWork(
props.selectedThread.pendingBackgroundTasks,
);
const [progressSheetOpen, setProgressSheetOpen] = useState(false);
const openProgressSheet = () => {
Keyboard.dismiss();
setProgressSheetOpen(true);
};
const threadProgress = useThreadProgress({
threadRef: props.selectedThreadDetailRef,
thread: props.selectedThread,
hasPendingApproval: props.activePendingApproval !== null,
hasPendingUserInput: props.activePendingUserInput !== null,
});
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 +681,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 +1415,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