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: 44 additions & 1 deletion apps/mobile/src/components/working-indicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,21 @@ const dots = [0, 1, 2, 3, 4, 5].map((bit) => ({
) as CSSAnimationKeyframes,
}));

export function WorkingIndicator() {
const blockPath = [0, 1, 2, 3, 4, 5, 6, 7, 6, 5, 4, 3, 2, 1];
const blocks = Array.from({ length: 8 }, (_, index) => ({
index,
animation: Object.fromEntries(
[...blockPath, blockPath[0]].map((head, frame) => {
const distance = frame < 8 ? (head ?? 0) - index : index - (head ?? 0);
return [
`${(frame / blockPath.length) * 100}%`,
{ opacity: distance >= 0 && distance < 4 ? 1 - distance * 0.22 : 0.15 },
];
}),
) as CSSAnimationKeyframes,
}));

export function WorkingIndicator({ variant = "dots" }: { variant?: "dots" | "blocks" }) {
const [reducedMotion, setReducedMotion] = useState(true);
const [foreground, setForeground] = useState(AppState.currentState === "active");

Expand All @@ -36,6 +50,33 @@ export function WorkingIndicator() {
};
}, []);

if (variant === "blocks") {
return (
<View
accessible={false}
importantForAccessibility="no-hide-descendants"
style={styles.blocks}
>
{blocks.map(({ index, animation }) => (
<Animated.View
key={index}
style={[
styles.block,
{ opacity: index < 4 ? 1 - index * 0.22 : 0.15 },
!reducedMotion && {
animationName: animation,
animationDuration: blockPath.length * 80,
animationIterationCount: "infinite",
animationTimingFunction: steps(1, "end"),
animationPlayState: foreground ? "running" : "paused",
},
]}
/>
))}
</View>
);
}

return (
<View accessible={false} importantForAccessibility="no-hide-descendants" style={styles.icon}>
{dots.map(({ bit, animation }) => (
Expand Down Expand Up @@ -63,6 +104,8 @@ export function WorkingIndicator() {
}

const styles = StyleSheet.create({
blocks: { flexDirection: "row", flexShrink: 0, gap: 1 },
block: { width: 5, height: 6, backgroundColor: palette.activity },
icon: { width: 10, height: 16, flexShrink: 0 },
dot: { position: "absolute", width: 4, height: 4, backgroundColor: palette.signal },
});
18 changes: 15 additions & 3 deletions apps/mobile/src/navigation/root-navigation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,13 @@ jest.mock("../screens/new-session-screen", () => {
const { Text } = jest.requireActual<typeof import("react-native")>("react-native");
return { NewSessionScreen: () => <Text>New session screen</Text> };
});
jest.mock("./workspace-header-actions", () => ({ WorkspaceHeaderActions: () => null }));
jest.mock("@expo/vector-icons/Feather", () => () => null);
jest.mock("../state/workspace-selection-context", () => ({
useWorkspaceSelection: () => ({
attentionCoverage: { completeness: "complete", freshness: "fresh" },
pendingCount: 0,
}),
}));
jest.mock("../screens/workspace-screen", () => {
const { Text } = jest.requireActual<typeof import("react-native")>("react-native");
return {
Expand Down Expand Up @@ -88,7 +94,7 @@ test("shows loading, failure, and first-run onboarding gates", () => {
expect(screen.getByText("Connection manager")).toBeOnTheScreen();
});

test("opens the configured workspace and returns from connection management", async () => {
test("opens new sessions from the header and connections from workspace options", async () => {
mockConnections = { profiles: [{ id: "connection-1" }], ready: true };
const navigation = createNavigationContainerRef<RootStackParamList>();
render(
Expand All @@ -98,7 +104,13 @@ test("opens the configured workspace and returns from connection management", as
);
expect(await screen.findByText("Workspace shell")).toBeOnTheScreen();

act(() => navigation.navigate("Connections"));
fireEvent.press(screen.getByRole("button", { name: "New session" }));
expect(await screen.findByText("New session screen")).toBeOnTheScreen();
act(() => navigation.goBack());
await screen.findByText("Workspace shell");

fireEvent.press(screen.getByRole("button", { name: "Workspace options" }));
fireEvent.press(screen.getByRole("button", { name: "Connections" }));
expect(await screen.findByText("Connection manager")).toBeOnTheScreen();

fireEvent.press(screen.getByRole("button", { name: "Connection manager" }));
Expand Down
9 changes: 7 additions & 2 deletions apps/mobile/src/navigation/root-navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export function RootNavigation() {
header: () => (
<WorkspaceHeader
navigate={(destination) => navigation.navigate(destination)}
onNewSession={() => navigation.navigate("NewSession")}
title="Sessions"
/>
),
Expand All @@ -105,6 +106,7 @@ export function RootNavigation() {
headerRight: () => (
<WorkspaceHeaderActions
navigate={(destination) => navigation.navigate(destination)}
onNewSession={() => navigation.navigate("NewSession")}
/>
),
}),
Expand Down Expand Up @@ -201,10 +203,12 @@ export function RootNavigation() {
function WorkspaceHeader({
navigate,
onBack,
onNewSession,
title,
}: {
navigate: (destination: "Connections" | "FollowedProjects" | "Pending" | "Settings") => void;
onBack?: () => void;
onNewSession?: () => void;
title: string;
}) {
return (
Expand All @@ -226,12 +230,12 @@ function WorkspaceHeader({
/>
</Pressable>
) : (
<View style={styles.headerSide} />
<View style={[styles.headerSide, onNewSession && styles.headerActionsSpacer]} />
)}
<Text accessibilityRole="header" numberOfLines={1} style={styles.headerTitle}>
{title}
</Text>
<WorkspaceHeaderActions navigate={navigate} />
<WorkspaceHeaderActions navigate={navigate} onNewSession={onNewSession} />
</View>
</SafeAreaView>
);
Expand Down Expand Up @@ -261,6 +265,7 @@ function useReducedMotion() {
}

const styles = StyleSheet.create({
headerActionsSpacer: { width: 88 },
header: {
alignItems: "center",
flexDirection: "row",
Expand Down
26 changes: 26 additions & 0 deletions apps/mobile/src/navigation/workspace-header-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ type HeaderDestination = "Connections" | "FollowedProjects" | "Pending" | "Setti

export function WorkspaceHeaderActions({
navigate,
onNewSession,
}: {
navigate: (destination: HeaderDestination) => void;
onNewSession?: (() => void) | undefined;
}) {
const selection = useWorkspaceSelection();
const [menuOpen, setMenuOpen] = useState(false);
Expand All @@ -36,6 +38,7 @@ export function WorkspaceHeaderActions({

return (
<View style={styles.actions}>
{onNewSession ? <NewSessionButton onPress={onNewSession} /> : null}
<Pressable
accessibilityHint="Opens workspace options"
accessibilityLabel="Workspace options"
Expand Down Expand Up @@ -88,6 +91,29 @@ export function WorkspaceHeaderActions({
);
}

export function NewSessionButton({ onPress }: { onPress: () => void }) {
return (
<Pressable
accessibilityHint="Choose a project for a new session"
accessibilityLabel="New session"
accessibilityRole="button"
onPress={() => {
Keyboard.dismiss();
onPress();
}}
style={({ pressed }) => [styles.optionsButton, pressed && styles.optionsButtonPressed]}
>
<Feather
accessibilityElementsHidden
color={palette.ink}
importantForAccessibility="no-hide-descendants"
name="edit"
size={24}
/>
</Pressable>
);
}

function MenuButton({
description,
label,
Expand Down
8 changes: 4 additions & 4 deletions apps/mobile/src/screens/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -486,7 +486,7 @@ export function getConnectionPresentation(
status: ConnectionTransportStatus,
reconnectAttempt: number,
) {
if (status === "connected") return { color: palette.signal, label: "LIVE" };
if (status === "connected") return { color: palette.success, label: "LIVE" };
if (status === "connecting") return { color: palette.warm, label: "CONNECTING" };
if (status === "reconnecting") {
return { color: palette.warm, label: `RECONNECTING ${reconnectAttempt}` };
Expand Down Expand Up @@ -786,7 +786,7 @@ export function WorkspaceStateCard({ state }: { state: ReturnType<typeof getWork
if (state === "loading") {
return (
<View accessibilityLiveRegion="polite" style={styles.stateCard}>
<ActivityIndicator color={palette.warm} />
<ActivityIndicator color={palette.dim} />
<View style={styles.stateCardText}>
<Text style={styles.cardTitle}>Loading server state</Text>
<Text style={styles.cardCopy}>Waiting for the first authoritative snapshot.</Text>
Expand Down Expand Up @@ -926,7 +926,7 @@ const styles = StyleSheet.create({
},
brand: { color: palette.signal, fontSize: 15, fontWeight: "700" },
cacheCard: {
backgroundColor: "#211B11",
backgroundColor: palette.background,
borderColor: palette.warm,
borderRadius: radius.lg,
borderWidth: 1,
Expand Down Expand Up @@ -1010,7 +1010,7 @@ const styles = StyleSheet.create({
errorText: { color: palette.danger, fontSize: 14, lineHeight: 20, marginTop: space.sm },
eyebrow: { color: palette.signal, fontSize: 11, fontWeight: "900", letterSpacing: 1.4 },
failureCard: {
backgroundColor: "#251411",
backgroundColor: palette.background,
borderColor: palette.danger,
borderRadius: radius.lg,
borderWidth: 1,
Expand Down
36 changes: 18 additions & 18 deletions apps/mobile/src/screens/connection-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1184,8 +1184,8 @@ const styles = StyleSheet.create({
maxWidth: 680,
},
error: {
backgroundColor: "#281513",
borderColor: "#5C2D27",
backgroundColor: palette.background,
borderColor: palette.danger,
borderRadius: radius.sm,
borderWidth: 1,
color: palette.danger,
Expand Down Expand Up @@ -1302,8 +1302,8 @@ const styles = StyleSheet.create({
letterSpacing: 1.3,
},
lifecyclePrompt: {
backgroundColor: "#21190F",
borderColor: "#4B3820",
backgroundColor: palette.background,
borderColor: palette.warm,
borderRadius: radius.md,
borderWidth: 1,
marginTop: space.md,
Expand Down Expand Up @@ -1331,11 +1331,11 @@ const styles = StyleSheet.create({
fontWeight: "600",
},
notice: {
backgroundColor: palette.signalDark,
borderColor: "#425E26",
backgroundColor: palette.background,
borderColor: palette.success,
borderRadius: radius.sm,
borderWidth: 1,
color: palette.signal,
color: palette.success,
fontSize: 14,
lineHeight: 20,
padding: space.md,
Expand All @@ -1360,7 +1360,7 @@ const styles = StyleSheet.create({
paddingHorizontal: space.md,
},
primaryButtonDisabled: { opacity: 0.65 },
primaryButtonPressed: { backgroundColor: "#9BD955" },
primaryButtonPressed: { opacity: 0.7 },
primaryLabel: {
color: palette.background,
fontSize: 14,
Expand Down Expand Up @@ -1426,16 +1426,16 @@ const styles = StyleSheet.create({
letterSpacing: 1.4,
},
result: {
backgroundColor: palette.signalDark,
borderColor: "#425E26",
backgroundColor: palette.background,
borderColor: palette.success,
borderRadius: radius.lg,
borderWidth: 1,
gap: space.lg,
marginTop: space.md,
padding: space.lg,
},
resultDot: {
backgroundColor: palette.signal,
backgroundColor: palette.success,
borderRadius: 5,
height: 10,
width: 10,
Expand All @@ -1446,7 +1446,7 @@ const styles = StyleSheet.create({
gap: space.sm,
},
resultTitle: {
color: palette.signal,
color: palette.success,
fontSize: 12,
fontWeight: "900",
letterSpacing: 1.2,
Expand Down Expand Up @@ -1478,7 +1478,7 @@ const styles = StyleSheet.create({
height: 8,
width: 8,
},
runtimeDotConnected: { backgroundColor: palette.signal },
runtimeDotConnected: { backgroundColor: palette.success },
runtimeStatus: {
borderBottomColor: palette.border,
borderBottomWidth: 1,
Expand All @@ -1498,7 +1498,7 @@ const styles = StyleSheet.create({
minHeight: 40,
paddingHorizontal: space.sm,
},
removeButtonPressed: { backgroundColor: "#281513" },
removeButtonPressed: { backgroundColor: palette.card },
removeLabel: {
color: palette.danger,
fontSize: 10,
Expand Down Expand Up @@ -1596,7 +1596,7 @@ const styles = StyleSheet.create({
segmentLabelSelected: { color: palette.background },
segmentSelected: { backgroundColor: palette.signal },
statusMark: {
backgroundColor: palette.signal,
backgroundColor: palette.success,
borderRadius: 4,
height: 8,
width: 8,
Expand Down Expand Up @@ -1639,16 +1639,16 @@ const styles = StyleSheet.create({
warningCopy: { flex: 1 },
warningRow: {
alignItems: "flex-start",
backgroundColor: "#21190F",
borderColor: "#4B3820",
backgroundColor: palette.background,
borderColor: palette.warm,
borderRadius: radius.md,
borderWidth: 1,
flexDirection: "row",
gap: space.sm,
padding: space.md,
},
warningText: {
color: "#C8B79E",
color: palette.dim,
fontSize: 13,
lineHeight: 19,
marginTop: space.xs,
Expand Down
11 changes: 9 additions & 2 deletions apps/mobile/src/screens/diff-screen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { FileDiffInfo } from "@opencode2-mobile/opencode-adapter";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react-native";

import { diffPalette } from "../theme";
import { buildDiffRows, DiffScreen } from "./diff-screen";

const mockGetDiff =
Expand Down Expand Up @@ -53,8 +54,14 @@ 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: "#172B38" });
expect(screen.getByText("-old value")).toHaveStyle({ backgroundColor: "#2A1714" });
expect(screen.getByText("+new value")).toHaveStyle({
backgroundColor: diffPalette.addedBackground,
color: diffPalette.addedText,
});
expect(screen.getByText("-old value")).toHaveStyle({
backgroundColor: diffPalette.removedBackground,
color: diffPalette.removedText,
});
expect(mockGetDiff).toHaveBeenCalledWith(
{},
{ directory: "/workspace" },
Expand Down
Loading
Loading