500 lines · 18.2 KB · TypeScript
RawDownload
import { QueryClient, QueryClientProvider, useQuery, useQueryClient } from "@tanstack/react-query";import type { OrchestrationEvent, OrchestrationReadModel, WsWelcomePayload } from "@t3tools/contracts";import { StatusBar } from "expo-status-bar";import { useCallback, useEffect, useMemo, useRef, useState } from "react";import {  ActivityIndicator,  Pressable,  SafeAreaView,  ScrollView,  Text,  TextInput,  View,} from "react-native"; import {  createBackendProfileId,  loadBackendState,  normalizeBackendInput,  saveBackendProfiles,  setActiveBackendProfileId,  type BackendProfile,} from "./backendProfiles";import { T3MobileClient } from "./t3MobileClient";import type { TransportStatus } from "./wsTransport"; const queryClient = new QueryClient({  defaultOptions: {    queries: {      staleTime: 5_000,      retry: 1,    },  },}); type ThemeButtonVariant = "primary" | "ghost" | "danger"; function ThemeButton(props: {  readonly label: string;  readonly onPress: () => void;  readonly disabled?: boolean;  readonly variant?: ThemeButtonVariant;}) {  const { label, onPress, disabled = false, variant = "primary" } = props;  const backgroundClassName =    variant === "primary"      ? "bg-blue-600"      : variant === "danger"        ? "bg-red-600"        : "bg-slate-200";  const textClassName = variant === "ghost" ? "text-slate-700" : "text-white";   return (    <Pressable      className={`h-11 items-center justify-center rounded-xl px-4 ${backgroundClassName} ${disabled ? "opacity-50" : "opacity-100"}`}      disabled={disabled}      onPress={onPress}    >      <Text className={`text-sm font-semibold ${textClassName}`}>{label}</Text>    </Pressable>  );} function SectionCard(props: { readonly title: string; readonly children: React.ReactNode }) {  return (    <View className="gap-3 rounded-2xl border border-slate-200 bg-white p-4">      <Text className="text-xs font-semibold uppercase tracking-[1.5px] text-slate-500">{props.title}</Text>      {props.children}    </View>  );} function statusLabel(status: TransportStatus): string {  if (status.state === "connected") return "Connected";  if (status.state === "connecting") return "Connecting";  if (status.state === "error") return "Connection Error";  return "Disconnected";} function statusClassName(status: TransportStatus): string {  if (status.state === "connected") return "bg-emerald-100 text-emerald-700";  if (status.state === "connecting") return "bg-amber-100 text-amber-700";  if (status.state === "error") return "bg-red-100 text-red-700";  return "bg-slate-200 text-slate-700";} function formatDate(iso: string | null | undefined): string {  if (!iso) return "-";   const value = new Date(iso);  if (Number.isNaN(value.getTime())) return "-";  return value.toLocaleString();} function formatEventType(event: OrchestrationEvent): string {  const eventRecord = event as Record<string, unknown>;  if (typeof eventRecord.type === "string") return eventRecord.type;  return "event";} function formatEventTimestamp(event: OrchestrationEvent): string {  const eventRecord = event as Record<string, unknown>;  if (typeof eventRecord.createdAt === "string") return formatDate(eventRecord.createdAt);  return "";} function eventKey(event: OrchestrationEvent): string {  const eventRecord = event as Record<string, unknown>;  if (typeof eventRecord.eventId === "string") return eventRecord.eventId;  if (typeof eventRecord.id === "string") return eventRecord.id;  return `${formatEventType(event)}-${formatEventTimestamp(event)}`;} function AppContent() {  const queryClient = useQueryClient();  const cleanupRef = useRef<(() => void) | null>(null);   const [profiles, setProfiles] = useState<BackendProfile[]>([]);  const [activeProfileId, setActiveProfileId] = useState<string | null>(null);  const [initialActiveProfileId, setInitialActiveProfileId] = useState<string | null>(null);  const [loadingProfiles, setLoadingProfiles] = useState(true);   const [wsUrlInput, setWsUrlInput] = useState("");  const [authTokenInput, setAuthTokenInput] = useState("");   const [client, setClient] = useState<T3MobileClient | null>(null);  const [connectionStatus, setConnectionStatus] = useState<TransportStatus>({    state: "disconnected",  });  const [welcome, setWelcome] = useState<WsWelcomePayload | null>(null);  const [recentEvents, setRecentEvents] = useState<OrchestrationEvent[]>([]);  const [uiError, setUiError] = useState<string | null>(null);   const activeProfile = useMemo(    () => profiles.find((profile) => profile.id === activeProfileId) ?? null,    [activeProfileId, profiles],  );   const snapshotQuery = useQuery<OrchestrationReadModel>({    queryKey: ["orchestration.snapshot", activeProfileId],    queryFn: async () => {      if (!client) {        throw new Error("No active backend connection.");      }      return client.getSnapshot();    },    enabled: client !== null && activeProfileId !== null,    refetchInterval: connectionStatus.state === "connected" ? 10_000 : false,  });   const disconnectFromBackend = useCallback(async () => {    cleanupRef.current?.();    cleanupRef.current = null;     setClient(null);    setActiveProfileId(null);    setWelcome(null);    setRecentEvents([]);    setConnectionStatus({ state: "disconnected" });     await setActiveBackendProfileId(null);    queryClient.removeQueries({ queryKey: ["orchestration.snapshot"] });  }, [queryClient]);   const connectToProfile = useCallback(    async (profile: BackendProfile) => {      setUiError(null);      cleanupRef.current?.();      cleanupRef.current = null;       setWelcome(null);      setRecentEvents([]);      setConnectionStatus({ state: "connecting" });       const nextClient = new T3MobileClient({        profile,        onStatus: (status) => {          setConnectionStatus(status);        },      });       const unsubscribeWelcome = nextClient.onWelcome((payload) => {        setWelcome(payload);      });       const unsubscribeDomainEvents = nextClient.onDomainEvent((event) => {        setRecentEvents((current) => [event, ...current].slice(0, 60));        void queryClient.invalidateQueries({ queryKey: ["orchestration.snapshot", profile.id] });      });       cleanupRef.current = () => {        unsubscribeWelcome();        unsubscribeDomainEvents();        nextClient.dispose();      };       setClient(nextClient);      setActiveProfileId(profile.id);      await setActiveBackendProfileId(profile.id);       const now = new Date().toISOString();      setProfiles((current) => {        const nextProfiles = current.map((candidate) =>          candidate.id === profile.id ? { ...candidate, lastConnectedAt: now } : candidate,        );        void saveBackendProfiles(nextProfiles);        return nextProfiles;      });       void queryClient.invalidateQueries({ queryKey: ["orchestration.snapshot", profile.id] });    },    [queryClient],  );   const saveAndConnectProfile = useCallback(async () => {    try {      setUiError(null);      const normalized = normalizeBackendInput(wsUrlInput, authTokenInput);      const now = new Date().toISOString();       const existing = profiles.find(        (profile) => profile.url === normalized.url && (profile.authToken ?? "") === (normalized.authToken ?? ""),      );       const profile: BackendProfile = existing        ? {            ...existing,            name: normalized.name,          }        : {            id: createBackendProfileId(),            name: normalized.name,            url: normalized.url,            authToken: normalized.authToken,            createdAt: now,            lastConnectedAt: null,          };       const nextProfiles = existing        ? profiles.map((candidate) => (candidate.id === existing.id ? profile : candidate))        : [profile, ...profiles];       setProfiles(nextProfiles);      await saveBackendProfiles(nextProfiles);      await connectToProfile(profile);       setWsUrlInput("");      setAuthTokenInput("");    } catch (error) {      setUiError(error instanceof Error ? error.message : "Unable to save backend profile.");    }  }, [authTokenInput, connectToProfile, profiles, wsUrlInput]);   const removeProfile = useCallback(    async (profileId: string) => {      const nextProfiles = profiles.filter((profile) => profile.id !== profileId);      setProfiles(nextProfiles);      await saveBackendProfiles(nextProfiles);       if (activeProfileId === profileId) {        await disconnectFromBackend();      }       if (initialActiveProfileId === profileId) {        setInitialActiveProfileId(null);      }    },    [activeProfileId, disconnectFromBackend, initialActiveProfileId, profiles],  );   useEffect(() => {    let cancelled = false;     void (async () => {      const state = await loadBackendState();      if (cancelled) return;       setProfiles(state.profiles);      setInitialActiveProfileId(state.activeProfileId);      setLoadingProfiles(false);    })();     return () => {      cancelled = true;      cleanupRef.current?.();      cleanupRef.current = null;    };  }, []);   useEffect(() => {    if (loadingProfiles || !initialActiveProfileId) return;     const profile = profiles.find((candidate) => candidate.id === initialActiveProfileId);    setInitialActiveProfileId(null);     if (profile) {      void connectToProfile(profile);    }  }, [connectToProfile, initialActiveProfileId, loadingProfiles, profiles]);   const snapshotError = snapshotQuery.error instanceof Error ? snapshotQuery.error.message : null;  const snapshot = snapshotQuery.data;   if (loadingProfiles) {    return (      <SafeAreaView className="flex-1 items-center justify-center bg-slate-100">        <ActivityIndicator size="small" color="#334155" />        <Text className="mt-3 text-sm text-slate-600">Loading servers...</Text>      </SafeAreaView>    );  }   if (client && activeProfile) {    return (      <SafeAreaView className="flex-1 bg-slate-100">        <StatusBar />        <ScrollView contentContainerStyle={{ padding: 16, gap: 14, paddingBottom: 40 }}>          <View className="rounded-2xl border border-slate-200 bg-white p-4">            <View className="flex-row items-start justify-between">              <View className="max-w-[75%] gap-1">                <Text className="text-xl font-semibold text-slate-900">T3 Code Mobile</Text>                <Text className="text-xs text-slate-600">{activeProfile.name}</Text>                <Text className="text-[11px] text-slate-500">{activeProfile.url}</Text>              </View>              <View className={`rounded-full px-3 py-1 ${statusClassName(connectionStatus)}`}>                <Text className="text-xs font-semibold">{statusLabel(connectionStatus)}</Text>              </View>            </View>            {connectionStatus.detail ? (              <Text className="mt-3 text-[11px] text-slate-500">{connectionStatus.detail}</Text>            ) : null}            <View className="mt-4 flex-row gap-2">              <View className="flex-1">                <ThemeButton                  label={snapshotQuery.isFetching ? "Refreshing..." : "Refresh Snapshot"}                  onPress={() => {                    void snapshotQuery.refetch();                  }}                  disabled={snapshotQuery.isFetching}                />              </View>              <View className="flex-1">                <ThemeButton label="Switch Server" variant="ghost" onPress={() => void disconnectFromBackend()} />              </View>            </View>          </View>           <SectionCard title="Session">            <Text className="text-sm text-slate-700">Project: {welcome?.projectName ?? "Waiting for welcome event..."}</Text>            <Text className="text-sm text-slate-700">Workspace: {welcome?.cwd ?? "-"}</Text>            <Text className="text-sm text-slate-700">              Bootstrap Thread: {welcome?.bootstrapThreadId ?? "not provided"}            </Text>          </SectionCard>           <SectionCard title="Orchestration Snapshot">            {snapshotQuery.isLoading ? (              <View className="items-start gap-2">                <ActivityIndicator size="small" color="#334155" />                <Text className="text-sm text-slate-600">Loading snapshot...</Text>              </View>            ) : snapshotError ? (              <Text className="text-sm text-red-700">{snapshotError}</Text>            ) : snapshot ? (              <View className="gap-2">                <Text className="text-sm text-slate-700">Projects: {snapshot.projects.length}</Text>                <Text className="text-sm text-slate-700">Threads: {snapshot.threads.length}</Text>                <Text className="text-sm text-slate-700">Updated: {formatDate(snapshot.updatedAt)}</Text>                <View className="mt-1 gap-2 rounded-xl border border-slate-200 bg-slate-50 p-3">                  {snapshot.projects.slice(0, 4).map((project) => (                    <View key={project.id} className="gap-0.5">                      <Text className="text-sm font-medium text-slate-800">{project.title}</Text>                      <Text className="text-[11px] text-slate-500">{project.workspaceRoot}</Text>                    </View>                  ))}                  {snapshot.projects.length === 0 ? (                    <Text className="text-sm text-slate-500">No projects available.</Text>                  ) : null}                </View>              </View>            ) : (              <Text className="text-sm text-slate-600">No snapshot data available.</Text>            )}          </SectionCard>           <SectionCard title="Recent Domain Events">            {recentEvents.length === 0 ? (              <Text className="text-sm text-slate-500">No events received yet.</Text>            ) : (              <View className="gap-2">                {recentEvents.slice(0, 25).map((event) => (                  <View                    key={eventKey(event)}                    className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2"                  >                    <Text className="text-xs font-semibold uppercase text-slate-700">{formatEventType(event)}</Text>                    <Text className="mt-1 text-[11px] text-slate-500">{formatEventTimestamp(event)}</Text>                  </View>                ))}              </View>            )}          </SectionCard>        </ScrollView>      </SafeAreaView>    );  }   return (    <SafeAreaView className="flex-1 bg-slate-100">      <StatusBar />      <ScrollView contentContainerStyle={{ padding: 16, gap: 14, paddingBottom: 40 }}>        <View className="rounded-2xl border border-slate-200 bg-white p-4">          <Text className="text-2xl font-semibold text-slate-900">Connect T3 Code</Text>          <Text className="mt-2 text-sm leading-5 text-slate-600">            Enter your websocket server URL and optional auth token. Saved servers appear below.          </Text>           <View className="mt-4 gap-3">            <View className="gap-1.5">              <Text className="text-xs font-semibold uppercase tracking-[1.4px] text-slate-500">Server URL</Text>              <TextInput                className="h-11 rounded-xl border border-slate-300 bg-white px-3 text-sm text-slate-900"                autoCapitalize="none"                autoCorrect={false}                keyboardType="url"                placeholder="ws://localhost:3773"                placeholderTextColor="#94a3b8"                value={wsUrlInput}                onChangeText={setWsUrlInput}              />            </View>            <View className="gap-1.5">              <Text className="text-xs font-semibold uppercase tracking-[1.4px] text-slate-500">Auth Token (Optional)</Text>              <TextInput                className="h-11 rounded-xl border border-slate-300 bg-white px-3 text-sm text-slate-900"                autoCapitalize="none"                autoCorrect={false}                placeholder="token"                placeholderTextColor="#94a3b8"                value={authTokenInput}                onChangeText={setAuthTokenInput}              />            </View>            <ThemeButton label="Save and Connect" onPress={() => void saveAndConnectProfile()} />          </View>          {uiError ? <Text className="mt-3 text-sm text-red-700">{uiError}</Text> : null}        </View>         <SectionCard title="Saved Backends">          {profiles.length === 0 ? (            <Text className="text-sm text-slate-500">No servers saved yet.</Text>          ) : (            <View className="gap-3">              {profiles.map((profile) => (                <View key={profile.id} className="gap-3 rounded-xl border border-slate-200 bg-slate-50 p-3">                  <View className="gap-1">                    <Text className="text-sm font-semibold text-slate-900">{profile.name}</Text>                    <Text className="text-[11px] text-slate-600">{profile.url}</Text>                    <Text className="text-[11px] text-slate-500">                      Token: {profile.authToken ? "configured" : "none"}                    </Text>                    <Text className="text-[11px] text-slate-500">                      Last connected: {formatDate(profile.lastConnectedAt)}                    </Text>                  </View>                  <View className="flex-row gap-2">                    <View className="flex-1">                      <ThemeButton                        label="Connect"                        onPress={() => {                          void connectToProfile(profile);                        }}                      />                    </View>                    <View className="flex-1">                      <ThemeButton                        label="Delete"                        variant="danger"                        onPress={() => {                          void removeProfile(profile.id);                        }}                      />                    </View>                  </View>                </View>              ))}            </View>          )}        </SectionCard>      </ScrollView>    </SafeAreaView>  );} export default function App() {  return (    <QueryClientProvider client={queryClient}>      <AppContent />    </QueryClientProvider>  );}