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
68 changes: 68 additions & 0 deletions apps/mobile/src/components/working-indicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { useEffect, useState } from "react";
import { AccessibilityInfo, AppState, StyleSheet, View } from "react-native";
import Animated, { type CSSAnimationKeyframes, steps } from "react-native-reanimated";

import { palette } from "../theme";

// OpenCode's terminal dot spinner, rendered as pixels instead of font-dependent glyphs.
const frames = [0x0b, 0x19, 0x39, 0x38, 0x3c, 0x34, 0x26, 0x27, 0x07, 0x0f];
const dots = [0, 1, 2, 3, 4, 5].map((bit) => ({
bit,
animation: Object.fromEntries(
[...frames, frames[0]].map((frame, index) => [
`${index * 10}%`,
{ opacity: ((frame ?? 0) & (1 << bit)) !== 0 ? 1 : 0 },
]),
) as CSSAnimationKeyframes,
}));

export function WorkingIndicator() {
const [reducedMotion, setReducedMotion] = useState(true);
const [foreground, setForeground] = useState(AppState.currentState === "active");

useEffect(() => {
let mounted = true;
void AccessibilityInfo.isReduceMotionEnabled()
.then((enabled) => {
if (mounted) setReducedMotion(enabled);
})
.catch(() => undefined);
const motion = AccessibilityInfo.addEventListener("reduceMotionChanged", setReducedMotion);
const app = AppState.addEventListener("change", (state) => setForeground(state === "active"));
return () => {
mounted = false;
motion.remove();
app.remove();
};
}, []);

return (
<View accessible={false} importantForAccessibility="no-hide-descendants" style={styles.icon}>
{dots.map(({ bit, animation }) => (
<Animated.View
key={bit}
style={[
styles.dot,
{
left: bit < 3 ? 0 : 6,
top: (bit % 3) * 6,
opacity: (0x0b & (1 << bit)) !== 0 ? 1 : 0,
},
!reducedMotion && {
animationName: animation,
animationDuration: 800,
animationIterationCount: "infinite",
animationTimingFunction: steps(1, "end"),
animationPlayState: foreground ? "running" : "paused",
},
]}
/>
))}
</View>
);
}

const styles = StyleSheet.create({
icon: { width: 10, height: 16, flexShrink: 0 },
dot: { position: "absolute", width: 4, height: 4, backgroundColor: palette.signal },
});
2 changes: 1 addition & 1 deletion apps/mobile/src/screens/diff-screen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ test("renders an authoritative working-tree diff", async () => {
"Current working tree. This may include changes made after the selected tool call.",
),
).toBeOnTheScreen();
expect(screen.getByText("+new value")).toHaveStyle({ backgroundColor: "#18230E" });
expect(screen.getByText("+new value")).toHaveStyle({ backgroundColor: "#172B38" });
expect(screen.getByText("-old value")).toHaveStyle({ backgroundColor: "#2A1714" });
expect(mockGetDiff).toHaveBeenCalledWith(
{},
Expand Down
13 changes: 11 additions & 2 deletions apps/mobile/src/screens/session-execution-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
import type { ReactNode } from "react";
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";

import { WorkingIndicator } from "../components/working-indicator";
import { palette, radius, space, typeRamp } from "../theme";
import { permissionActionExplanation } from "./permission-presentation";
import {
Expand Down Expand Up @@ -76,9 +77,17 @@ export function SessionExecutionPanel({
{active ? (
<View style={styles.executionCard}>
<View style={styles.headingRow}>
<View style={styles.activeDot} />
{permissions.length > 0 || formRequests ? (
<View style={styles.activeDot} />
) : (
<WorkingIndicator />
)}
<Text dynamicTypeRamp={typeRamp.control} style={styles.executionTitle}>
{permissions.length > 0 ? "Waiting for permission" : "OpenCode is working"}
{permissions.length > 0
? "Waiting for permission"
: formRequests
? "Waiting for input"
: "OpenCode is working"}
</Text>
</View>
<View style={styles.actionRow}>
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/screens/session-transcript.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ test("opens HTTP and HTTPS transcript URLs as confirmed external links", () => {
);

const secureLink = screen.getByRole("link", { name: "https://example.test/docs" });
expect(secureLink).toHaveStyle({ color: "#B6F26C", textDecorationLine: "underline" });
expect(secureLink).toHaveStyle({ color: "#36C5E5", textDecorationLine: "underline" });
expect(screen.getByRole("link", { name: "http://localhost:4096/status" })).toBeOnTheScreen();
expect(screen.getByRole("link", { name: "https://assistant.test/guide" })).toHaveStyle({
fontWeight: "800",
Expand Down Expand Up @@ -267,7 +267,7 @@ test("renders fenced assistant code without markdown fence markers", () => {
expect(screen.getByText("Then inspect the stack.")).toBeOnTheScreen();
expect(screen.queryByText(/```/)).toBeNull();
expect(screen.getByLabelText("Code block, gdb")).toHaveStyle({
backgroundColor: "#121614",
backgroundColor: "#241830",
borderWidth: 1,
});
});
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/src/screens/workspace-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
} from "react-native";
import ReanimatedSwipeable from "react-native-gesture-handler/ReanimatedSwipeable";
import { KeyboardStickyView } from "react-native-keyboard-controller";
import { WorkingIndicator } from "../components/working-indicator";
import { useConnections } from "../connections/connections-context";
import type { RootStackParamList } from "../navigation/root-navigation";
import { useConnectionRuntime } from "../state/connection-runtime-context";
Expand Down Expand Up @@ -1145,6 +1146,7 @@ function SessionRow({
pressed && styles.pressed,
]}
>
{active && !blocked ? <WorkingIndicator /> : null}
<View style={styles.sessionMain}>
<View style={styles.sessionTopRow}>
<Text numberOfLines={1} style={styles.sessionProject}>
Expand Down Expand Up @@ -1202,6 +1204,7 @@ function SessionRow({
style={({ pressed }) => [styles.childRow, pressed && styles.pressed]}
>
<View style={styles.childBranch} />
{child.active && child.attentionCount === 0 ? <WorkingIndicator /> : null}
<Text numberOfLines={2} style={styles.childTitle}>
{child.session.title || "Untitled child session"}
</Text>
Expand Down
14 changes: 7 additions & 7 deletions apps/mobile/src/theme.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
export const palette = {
background: "#0B0D0C",
border: "#2A302D",
card: "#121614",
background: "#100B18",
border: "#392C48",
card: "#241830",
danger: "#FF8E7A",
dim: "#8E9993",
ink: "#F0F4F1",
signal: "#B6F26C",
signalDark: "#18230E",
dim: "#A69BAF",
ink: "#EAE2F0",
signal: "#36C5E5",
signalDark: "#172B38",
warm: "#FFB86B",
} as const;

Expand Down
Loading