diff --git a/.gitignore b/.gitignore index bce6f530372..99aff410fb6 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ node_modules dist dist-ssr dist-electron +dist-types release *.local diff --git a/.superset/lib/setup/steps.sh b/.superset/lib/setup/steps.sh index 2a3c2f43200..fca533b469e 100644 --- a/.superset/lib/setup/steps.sh +++ b/.superset/lib/setup/steps.sh @@ -551,6 +551,7 @@ step_write_env() { write_env_var "NEXT_PUBLIC_ELECTRIC_URL" "https://localhost:$CADDY_ELECTRIC_PORT" write_env_var "NEXT_PUBLIC_ELECTRIC_PROXY_URL" "https://localhost:$CADDY_ELECTRIC_PORT" write_env_var "EXPO_PUBLIC_ELECTRIC_URL" "http://localhost:$WRANGLER_PORT" + write_env_var "EXPO_PUBLIC_RELAY_URL" "http://localhost:$RELAY_PORT" } >> .env success "Workspace .env written" diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index c56d01f711d..c722d89d0de 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -55,6 +55,13 @@ export default ({ config }: ConfigContext) => ({ }, ], "expo-document-picker", + [ + "expo-media-library", + { + photosPermission: + "Superset shows your recent photos so you can attach them to chats.", + }, + ], ], extra: { router: {}, diff --git a/apps/mobile/app/(authenticated)/(home)/_layout.tsx b/apps/mobile/app/(authenticated)/(home)/_layout.tsx new file mode 100644 index 00000000000..d18feff733c --- /dev/null +++ b/apps/mobile/app/(authenticated)/(home)/_layout.tsx @@ -0,0 +1,32 @@ +import { Stack } from "expo-router"; + +export default function HomeLayout() { + return ( + + + + + + ); +} diff --git a/apps/mobile/app/(authenticated)/(home)/filter/_layout.tsx b/apps/mobile/app/(authenticated)/(home)/filter/_layout.tsx new file mode 100644 index 00000000000..d3658240cbb --- /dev/null +++ b/apps/mobile/app/(authenticated)/(home)/filter/_layout.tsx @@ -0,0 +1,17 @@ +import { Stack } from "expo-router"; + +export default function FilterLayout() { + return ( + + + + + + + ); +} diff --git a/apps/mobile/app/(authenticated)/(home)/filter/host.tsx b/apps/mobile/app/(authenticated)/(home)/filter/host.tsx new file mode 100644 index 00000000000..3025d647d5c --- /dev/null +++ b/apps/mobile/app/(authenticated)/(home)/filter/host.tsx @@ -0,0 +1,3 @@ +import { HostFilterScreen } from "@/screens/(authenticated)/(home)/filter/host"; + +export default HostFilterScreen; diff --git a/apps/mobile/app/(authenticated)/(home)/filter/index.tsx b/apps/mobile/app/(authenticated)/(home)/filter/index.tsx new file mode 100644 index 00000000000..3791674faf1 --- /dev/null +++ b/apps/mobile/app/(authenticated)/(home)/filter/index.tsx @@ -0,0 +1,3 @@ +import { FilterScreen } from "@/screens/(authenticated)/(home)/filter"; + +export default FilterScreen; diff --git a/apps/mobile/app/(authenticated)/(home)/filter/project.tsx b/apps/mobile/app/(authenticated)/(home)/filter/project.tsx new file mode 100644 index 00000000000..65924e59a74 --- /dev/null +++ b/apps/mobile/app/(authenticated)/(home)/filter/project.tsx @@ -0,0 +1,3 @@ +import { ProjectFilterScreen } from "@/screens/(authenticated)/(home)/filter/project"; + +export default ProjectFilterScreen; diff --git a/apps/mobile/app/(authenticated)/(home)/filter/sort.tsx b/apps/mobile/app/(authenticated)/(home)/filter/sort.tsx new file mode 100644 index 00000000000..acf9c1bf372 --- /dev/null +++ b/apps/mobile/app/(authenticated)/(home)/filter/sort.tsx @@ -0,0 +1,3 @@ +import { SortFilterScreen } from "@/screens/(authenticated)/(home)/filter/sort"; + +export default SortFilterScreen; diff --git a/apps/mobile/app/(authenticated)/(home)/index.tsx b/apps/mobile/app/(authenticated)/(home)/index.tsx new file mode 100644 index 00000000000..9cd0c421bb1 --- /dev/null +++ b/apps/mobile/app/(authenticated)/(home)/index.tsx @@ -0,0 +1,3 @@ +import { WorkspacesScreen } from "@/screens/(authenticated)/(home)/workspaces"; + +export default WorkspacesScreen; diff --git a/apps/mobile/app/(authenticated)/(home)/new-chat/_layout.tsx b/apps/mobile/app/(authenticated)/(home)/new-chat/_layout.tsx new file mode 100644 index 00000000000..9d35ccc2d27 --- /dev/null +++ b/apps/mobile/app/(authenticated)/(home)/new-chat/_layout.tsx @@ -0,0 +1,16 @@ +import { Stack } from "expo-router"; + +export default function NewChatLayout() { + return ( + + + + + + ); +} diff --git a/apps/mobile/app/(authenticated)/(home)/new-chat/branch.tsx b/apps/mobile/app/(authenticated)/(home)/new-chat/branch.tsx new file mode 100644 index 00000000000..3249052387c --- /dev/null +++ b/apps/mobile/app/(authenticated)/(home)/new-chat/branch.tsx @@ -0,0 +1,3 @@ +import { BranchPickerScreen } from "@/screens/(authenticated)/(home)/new-chat/branch"; + +export default BranchPickerScreen; diff --git a/apps/mobile/app/(authenticated)/(home)/new-chat/model.tsx b/apps/mobile/app/(authenticated)/(home)/new-chat/model.tsx new file mode 100644 index 00000000000..b843bf53759 --- /dev/null +++ b/apps/mobile/app/(authenticated)/(home)/new-chat/model.tsx @@ -0,0 +1,3 @@ +import { ModelPickerScreen } from "@/screens/(authenticated)/(home)/new-chat/model"; + +export default ModelPickerScreen; diff --git a/apps/mobile/app/(authenticated)/(home)/new-chat/project.tsx b/apps/mobile/app/(authenticated)/(home)/new-chat/project.tsx new file mode 100644 index 00000000000..051e640ad43 --- /dev/null +++ b/apps/mobile/app/(authenticated)/(home)/new-chat/project.tsx @@ -0,0 +1,3 @@ +import { ProjectPickerScreen } from "@/screens/(authenticated)/(home)/new-chat/project"; + +export default ProjectPickerScreen; diff --git a/apps/mobile/app/(authenticated)/(tabs)/(home)/_layout.tsx b/apps/mobile/app/(authenticated)/(tabs)/(home)/_layout.tsx deleted file mode 100644 index 6fade6e5bb5..00000000000 --- a/apps/mobile/app/(authenticated)/(tabs)/(home)/_layout.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { Stack } from "expo-router"; - -export default function HomeLayout() { - return ( - - - - ); -} diff --git a/apps/mobile/app/(authenticated)/(tabs)/(home)/index.tsx b/apps/mobile/app/(authenticated)/(tabs)/(home)/index.tsx deleted file mode 100644 index 28c8bdc70cf..00000000000 --- a/apps/mobile/app/(authenticated)/(tabs)/(home)/index.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import { WorkspacesScreen } from "@/screens/(authenticated)/(tabs)/(home)/workspaces"; - -export default WorkspacesScreen; diff --git a/apps/mobile/app/(authenticated)/(tabs)/(more)/_layout.tsx b/apps/mobile/app/(authenticated)/(tabs)/(more)/_layout.tsx deleted file mode 100644 index 29011e8087a..00000000000 --- a/apps/mobile/app/(authenticated)/(tabs)/(more)/_layout.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { Stack } from "expo-router"; -import { titledScreenOptions } from "@/lib/navigation"; - -export default function MoreLayout() { - return ( - - - - - ); -} diff --git a/apps/mobile/app/(authenticated)/(tabs)/(more)/index.tsx b/apps/mobile/app/(authenticated)/(tabs)/(more)/index.tsx deleted file mode 100644 index c855f07395c..00000000000 --- a/apps/mobile/app/(authenticated)/(tabs)/(more)/index.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import { MoreMenuScreen } from "@/screens/(authenticated)/(tabs)/(more)"; - -export default MoreMenuScreen; diff --git a/apps/mobile/app/(authenticated)/(tabs)/(more)/settings/index.tsx b/apps/mobile/app/(authenticated)/(tabs)/(more)/settings/index.tsx deleted file mode 100644 index 358c24c5835..00000000000 --- a/apps/mobile/app/(authenticated)/(tabs)/(more)/settings/index.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import { SettingsScreen } from "@/screens/(authenticated)/(tabs)/(more)/settings"; - -export default SettingsScreen; diff --git a/apps/mobile/app/(authenticated)/(tabs)/(search)/_layout.tsx b/apps/mobile/app/(authenticated)/(tabs)/(search)/_layout.tsx deleted file mode 100644 index 2c8ac9740b9..00000000000 --- a/apps/mobile/app/(authenticated)/(tabs)/(search)/_layout.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { Stack } from "expo-router"; -import { titledScreenOptions } from "@/lib/navigation"; - -export default function SearchLayout() { - return ( - - - - ); -} diff --git a/apps/mobile/app/(authenticated)/(tabs)/(search)/index.tsx b/apps/mobile/app/(authenticated)/(tabs)/(search)/index.tsx deleted file mode 100644 index 04004fb2fee..00000000000 --- a/apps/mobile/app/(authenticated)/(tabs)/(search)/index.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import { SearchScreen } from "@/screens/(authenticated)/(tabs)/(search)"; - -export default SearchScreen; diff --git a/apps/mobile/app/(authenticated)/(tabs)/(tasks)/[id].tsx b/apps/mobile/app/(authenticated)/(tabs)/(tasks)/[id].tsx deleted file mode 100644 index 8bb01a8a017..00000000000 --- a/apps/mobile/app/(authenticated)/(tabs)/(tasks)/[id].tsx +++ /dev/null @@ -1,3 +0,0 @@ -import { TaskDetailScreen } from "@/screens/(authenticated)/(tabs)/(tasks)/[id]"; - -export default TaskDetailScreen; diff --git a/apps/mobile/app/(authenticated)/(tabs)/(tasks)/_layout.tsx b/apps/mobile/app/(authenticated)/(tabs)/(tasks)/_layout.tsx deleted file mode 100644 index 51a40042926..00000000000 --- a/apps/mobile/app/(authenticated)/(tabs)/(tasks)/_layout.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { Stack } from "expo-router"; -import { titledScreenOptions } from "@/lib/navigation"; - -export default function TasksLayout() { - return ( - - - - - ); -} diff --git a/apps/mobile/app/(authenticated)/(tabs)/(tasks)/index.tsx b/apps/mobile/app/(authenticated)/(tabs)/(tasks)/index.tsx deleted file mode 100644 index d4a093c523a..00000000000 --- a/apps/mobile/app/(authenticated)/(tabs)/(tasks)/index.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import { TasksScreen } from "@/screens/(authenticated)/(tabs)/(tasks)/tasks"; - -export default TasksScreen; diff --git a/apps/mobile/app/(authenticated)/(tabs)/_layout.tsx b/apps/mobile/app/(authenticated)/(tabs)/_layout.tsx deleted file mode 100644 index 10a5c56c0cd..00000000000 --- a/apps/mobile/app/(authenticated)/(tabs)/_layout.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { NativeTabs } from "expo-router/unstable-native-tabs"; -import { THEME } from "@/lib/theme"; - -export default function TabsLayout() { - return ( - - - - Home - - - - Tasks - - - - Search - - - - More - - - ); -} diff --git a/apps/mobile/app/(authenticated)/_layout.tsx b/apps/mobile/app/(authenticated)/_layout.tsx index f9f89c40516..98b4688e421 100644 --- a/apps/mobile/app/(authenticated)/_layout.tsx +++ b/apps/mobile/app/(authenticated)/_layout.tsx @@ -2,14 +2,41 @@ import { Stack } from "expo-router"; import { useDevicePresence } from "@/hooks/useDevicePresence"; import { CollectionsProvider } from "@/screens/(authenticated)/providers/CollectionsProvider"; +const settingsScreenOptions = (title: string) => ({ + headerShown: true, + headerBackButtonDisplayMode: "minimal" as const, + headerShadowVisible: false, + title, +}); + export default function AuthenticatedLayout() { useDevicePresence(); return ( - + + + + + + ); diff --git a/apps/mobile/app/(authenticated)/settings/account.tsx b/apps/mobile/app/(authenticated)/settings/account.tsx new file mode 100644 index 00000000000..8287ea1e0f8 --- /dev/null +++ b/apps/mobile/app/(authenticated)/settings/account.tsx @@ -0,0 +1,3 @@ +import { AccountSettingsScreen } from "@/screens/(authenticated)/settings/account"; + +export default AccountSettingsScreen; diff --git a/apps/mobile/app/(authenticated)/settings/billing.tsx b/apps/mobile/app/(authenticated)/settings/billing.tsx new file mode 100644 index 00000000000..0fdcac96ba2 --- /dev/null +++ b/apps/mobile/app/(authenticated)/settings/billing.tsx @@ -0,0 +1,3 @@ +import { BillingSettingsScreen } from "@/screens/(authenticated)/settings/billing"; + +export default BillingSettingsScreen; diff --git a/apps/mobile/app/(authenticated)/settings/hosts.tsx b/apps/mobile/app/(authenticated)/settings/hosts.tsx new file mode 100644 index 00000000000..e9a712c7d85 --- /dev/null +++ b/apps/mobile/app/(authenticated)/settings/hosts.tsx @@ -0,0 +1,3 @@ +import { HostsSettingsScreen } from "@/screens/(authenticated)/settings/hosts"; + +export default HostsSettingsScreen; diff --git a/apps/mobile/app/(authenticated)/settings/index.tsx b/apps/mobile/app/(authenticated)/settings/index.tsx new file mode 100644 index 00000000000..af1c7925892 --- /dev/null +++ b/apps/mobile/app/(authenticated)/settings/index.tsx @@ -0,0 +1,3 @@ +import { SettingsScreen } from "@/screens/(authenticated)/settings"; + +export default SettingsScreen; diff --git a/apps/mobile/app/(authenticated)/settings/organization.tsx b/apps/mobile/app/(authenticated)/settings/organization.tsx new file mode 100644 index 00000000000..1a1abdd41a8 --- /dev/null +++ b/apps/mobile/app/(authenticated)/settings/organization.tsx @@ -0,0 +1,3 @@ +import { OrganizationSettingsScreen } from "@/screens/(authenticated)/settings/organization"; + +export default OrganizationSettingsScreen; diff --git a/apps/mobile/hooks/useHostWorkspaces/index.ts b/apps/mobile/hooks/useHostWorkspaces/index.ts new file mode 100644 index 00000000000..47833a495c6 --- /dev/null +++ b/apps/mobile/hooks/useHostWorkspaces/index.ts @@ -0,0 +1,9 @@ +export { + getHostWorkspacesQueryKey, + type HostWorkspaceItem, + type HostWorkspaceRow, + type HostWorkspacesCacheOps, + type UseHostWorkspacesResult, + useHostWorkspaces, + type WorkspacesHost, +} from "./useHostWorkspaces"; diff --git a/apps/mobile/hooks/useHostWorkspaces/useHostWorkspaces.ts b/apps/mobile/hooks/useHostWorkspaces/useHostWorkspaces.ts new file mode 100644 index 00000000000..69e60e62a18 --- /dev/null +++ b/apps/mobile/hooks/useHostWorkspaces/useHostWorkspaces.ts @@ -0,0 +1,124 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMemo } from "react"; +import { + buildRelayHostUrl, + getHostServiceClientByUrl, + type HostWorkspaceRow, +} from "@/lib/host-service/client"; + +export type { HostWorkspaceRow } from "@/lib/host-service/client"; + +export interface HostWorkspaceItem extends HostWorkspaceRow { + /** False when the rows are cached and the host stopped answering. */ + hostReachable: boolean; +} + +export interface WorkspacesHost { + organizationId: string; + machineId: string; + isOnline: boolean; +} + +const WORKSPACES_REFETCH_INTERVAL_MS = 30_000; + +export function getHostWorkspacesQueryKey( + machineId: string | null, + hostUrl: string | null, +) { + return ["host-service", "workspaces", "list", machineId, hostUrl] as const; +} + +export interface HostWorkspacesCacheOps { + /** Resolve the URL to reach the host owning `hostId` (null = unreachable). */ + resolveHostUrl: (hostId: string) => string | null; + /** Optimistically upsert a row into the host's cached list. */ + upsertWorkspace: (row: HostWorkspaceRow) => void; + /** Optimistically drop a row from the host's cached list. */ + removeWorkspace: (hostId: string, workspaceId: string) => void; + /** Rollback hammer: refetch the host's list after a failed write. */ + invalidateHost: (hostId: string) => void; +} + +export interface UseHostWorkspacesResult { + workspaces: HostWorkspaceItem[]; + /** + * True once the host answered or failed (or is offline). Gates empty + * states only — existing rows always render (cache-first rule). + */ + isReady: boolean; + cache: HostWorkspacesCacheOps; +} + +/** + * Workspaces served by one host's `workspace.list` over the relay. The + * 30s poll plus focus/pull refetch is the healing path; an offline host + * serves nothing and the UI shows a placeholder. + */ +export function useHostWorkspaces( + host: WorkspacesHost | null, +): UseHostWorkspacesResult { + const queryClient = useQueryClient(); + + const hostUrl = host?.isOnline + ? buildRelayHostUrl(host.organizationId, host.machineId) + : null; + const machineId = host?.machineId ?? null; + const queryKey = getHostWorkspacesQueryKey(machineId, hostUrl); + + const query = useQuery({ + queryKey, + enabled: hostUrl !== null, + refetchInterval: WORKSPACES_REFETCH_INTERVAL_MS, + retry: 1, + networkMode: "always" as const, + queryFn: async (): Promise => { + if (!hostUrl) return []; + return getHostServiceClientByUrl(hostUrl).workspace.list.query(); + }, + }); + + const workspaces = useMemo( + () => + (query.data ?? []).map((row) => ({ + ...row, + hostReachable: !query.isError, + })), + [query.data, query.isError], + ); + + const isReady = hostUrl === null || query.isSuccess || query.isError; + + const cache = useMemo(() => { + const key = getHostWorkspacesQueryKey(machineId, hostUrl); + return { + resolveHostUrl: (hostId) => (hostId === machineId ? hostUrl : null), + upsertWorkspace: (row) => { + if (row.hostId !== machineId) return; + queryClient.setQueryData( + key, + (rows) => { + if (!rows) return [row]; + const exists = rows.some((existing) => existing.id === row.id); + return exists + ? rows.map((existing) => + existing.id === row.id ? { ...existing, ...row } : existing, + ) + : [...rows, row]; + }, + ); + }, + removeWorkspace: (hostId, workspaceId) => { + if (hostId !== machineId) return; + queryClient.setQueryData(key, (rows) => + rows?.filter((row) => row.id !== workspaceId), + ); + }, + invalidateHost: (hostId) => { + if (hostId !== machineId) return; + void queryClient.invalidateQueries({ queryKey: key }); + }, + }; + }, [machineId, hostUrl, queryClient]); + + return { workspaces, isReady, cache }; +} diff --git a/apps/mobile/hooks/useWorkspaceHost/index.ts b/apps/mobile/hooks/useWorkspaceHost/index.ts new file mode 100644 index 00000000000..c2607302735 --- /dev/null +++ b/apps/mobile/hooks/useWorkspaceHost/index.ts @@ -0,0 +1,4 @@ +export { + useWorkspaceHost, + type WorkspaceHostResult, +} from "./useWorkspaceHost"; diff --git a/apps/mobile/hooks/useWorkspaceHost/useWorkspaceHost.ts b/apps/mobile/hooks/useWorkspaceHost/useWorkspaceHost.ts new file mode 100644 index 00000000000..d2470890aa0 --- /dev/null +++ b/apps/mobile/hooks/useWorkspaceHost/useWorkspaceHost.ts @@ -0,0 +1,74 @@ +import type { SelectV2Host } from "@superset/db/schema"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useQueries } from "@tanstack/react-query"; +import { useMemo } from "react"; +import { + getHostWorkspacesQueryKey, + type HostWorkspaceRow, +} from "@/hooks/useHostWorkspaces"; +import { + buildRelayHostUrl, + getHostServiceClientByUrl, +} from "@/lib/host-service/client"; +import { useCollections } from "@/screens/(authenticated)/providers/CollectionsProvider"; + +export interface WorkspaceHostResult { + workspace: HostWorkspaceRow | null; + host: SelectV2Host | null; + /** True while no host has answered yet. */ + isResolving: boolean; +} + +/** + * Locate a workspace's row (and owning host) by asking each online host. + * Query keys match useHostWorkspaces, so navigating from the list resolves + * straight from cache. + */ +export function useWorkspaceHost( + workspaceId: string | null, +): WorkspaceHostResult { + const collections = useCollections(); + + const { data: hosts } = useLiveQuery( + (q) => q.from({ v2Hosts: collections.v2Hosts }), + [collections], + ); + + const targets = useMemo( + () => + (hosts ?? []) + .filter((host) => host.isOnline) + .map((host) => ({ + host, + hostUrl: buildRelayHostUrl(host.organizationId, host.machineId), + })), + [hosts], + ); + + const queries = useQueries({ + queries: targets.map(({ host, hostUrl }) => ({ + queryKey: getHostWorkspacesQueryKey(host.machineId, hostUrl), + enabled: workspaceId !== null, + staleTime: 30_000, + retry: 1, + networkMode: "always" as const, + queryFn: async (): Promise => + getHostServiceClientByUrl(hostUrl).workspace.list.query(), + })), + }); + + return useMemo(() => { + let workspace: HostWorkspaceRow | null = null; + let host: SelectV2Host | null = null; + targets.forEach(({ host: target }, index) => { + if (workspace) return; + const match = queries[index]?.data?.find((row) => row.id === workspaceId); + if (match) { + workspace = match; + host = target; + } + }); + const isResolving = !workspace && queries.some((query) => query.isLoading); + return { workspace, host, isResolving }; + }, [targets, queries, workspaceId]); +} diff --git a/apps/mobile/lib/collections/collections.ts b/apps/mobile/lib/collections/collections.ts index c3f0da2e579..6ee00edc92b 100644 --- a/apps/mobile/lib/collections/collections.ts +++ b/apps/mobile/lib/collections/collections.ts @@ -10,12 +10,12 @@ import type { SelectMember, SelectOrganization, SelectProject, + SelectSubscription, SelectTask, SelectTaskStatus, SelectUser, SelectV2Host, SelectV2Project, - SelectV2Workspace, } from "@superset/db/schema"; import { electricCollectionOptions } from "@tanstack/electric-db-collection"; import type { Collection } from "@tanstack/react-db"; @@ -67,8 +67,8 @@ interface OrgCollections { users: Collection; invitations: Collection; v2Projects: Collection; - v2Workspaces: Collection; v2Hosts: Collection; + subscriptions: Collection; chatSessions: Collection; githubPullRequests: Collection; } @@ -209,33 +209,33 @@ function createOrgCollections(organizationId: string): OrgCollections { }), ); - const v2Workspaces = createCollection( - electricCollectionOptions({ - id: `v2-workspaces-${organizationId}`, + const v2Hosts = createCollection( + electricCollectionOptions({ + id: `v2-hosts-${organizationId}`, shapeOptions: { url: electricUrl, - params: { table: "v2_workspaces", organizationId }, + params: { table: "v2_hosts", organizationId }, headers: electricHeaders, columnMapper, parser, onError: handleElectricSyncError, }, - getKey: (item) => item.id, + getKey: (item) => item.machineId, }), ); - const v2Hosts = createCollection( - electricCollectionOptions({ - id: `v2-hosts-${organizationId}`, + const subscriptions = createCollection( + electricCollectionOptions({ + id: `subscriptions-${organizationId}`, shapeOptions: { url: electricUrl, - params: { table: "v2_hosts", organizationId }, + params: { table: "subscriptions", organizationId }, headers: electricHeaders, columnMapper, parser, onError: handleElectricSyncError, }, - getKey: (item) => item.machineId, + getKey: (item) => item.id, }), ); @@ -277,8 +277,8 @@ function createOrgCollections(organizationId: string): OrgCollections { users, invitations, v2Projects, - v2Workspaces, v2Hosts, + subscriptions, chatSessions, githubPullRequests, }; diff --git a/apps/mobile/lib/host-service/client.ts b/apps/mobile/lib/host-service/client.ts new file mode 100644 index 00000000000..a5276e16cab --- /dev/null +++ b/apps/mobile/lib/host-service/client.ts @@ -0,0 +1,55 @@ +// INTERIM: AppRouter comes from generated dist-types until the wire contract +// moves to a neutral package — see packages/host-service/docs/interim-router-types.md +import type { AppRouter } from "@superset/host-service/router"; +import { buildHostRoutingKey } from "@superset/shared/host-routing"; +import { createTRPCClient, httpLink, type TRPCClient } from "@trpc/client"; +import type { inferRouterOutputs } from "@trpc/server"; +import superjson from "superjson"; +import { getJwt } from "../auth/client"; +import { env } from "../env"; + +export type HostServiceClient = TRPCClient; + +type RouterOutputs = inferRouterOutputs; + +export type HostWorkspaceRow = RouterOutputs["workspace"]["list"][number]; +export type GitStatusSnapshot = RouterOutputs["git"]["getStatus"]; +export type ChangedFileStats = GitStatusSnapshot["againstBase"][number]; +export type DestroyWorkspaceResult = + RouterOutputs["workspaceCleanup"]["destroy"]; +export type CreateWorkspaceResult = RouterOutputs["workspaces"]["create"]; +export type AgentLaunchResult = CreateWorkspaceResult["agents"][number]; +export type BranchSearchResult = + RouterOutputs["workspaceCreation"]["searchBranches"]; +export type BranchSearchRow = BranchSearchResult["items"][number]; +export type HostProjectRow = RouterOutputs["project"]["list"][number]; + +const clientCache = new Map(); + +export function buildRelayHostUrl( + organizationId: string, + machineId: string, +): string { + return `${env.EXPO_PUBLIC_RELAY_URL}/hosts/${buildHostRoutingKey(organizationId, machineId)}`; +} + +export function getHostServiceClientByUrl(hostUrl: string): HostServiceClient { + const cached = clientCache.get(hostUrl); + if (cached) return cached; + + const client = createTRPCClient({ + links: [ + httpLink({ + url: `${hostUrl}/trpc`, + transformer: superjson, + headers: () => { + const jwt = getJwt(); + return jwt ? { Authorization: `Bearer ${jwt}` } : {}; + }, + }), + ], + }); + + clientCache.set(hostUrl, client); + return client; +} diff --git a/apps/mobile/lib/host-service/errors.ts b/apps/mobile/lib/host-service/errors.ts new file mode 100644 index 00000000000..d72870b5ead --- /dev/null +++ b/apps/mobile/lib/host-service/errors.ts @@ -0,0 +1,17 @@ +import { TRPCClientError } from "@trpc/client"; + +interface HostServiceErrorData { + code?: string; + deleteInProgress?: unknown; + teardownFailure?: unknown; +} + +export function isTrpcErrorWithData( + error: unknown, +): error is { data: HostServiceErrorData } { + return ( + error instanceof TRPCClientError && + typeof error.data === "object" && + error.data !== null + ); +} diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js index edd3f5cd769..c148c1ef24e 100644 --- a/apps/mobile/metro.config.js +++ b/apps/mobile/metro.config.js @@ -33,6 +33,7 @@ config.resolver.unstable_enablePackageExports = true; // Resolve local Expo Modules (modules/ dir) config.resolver.extraNodeModules = { + "@superset/alert-prompt": path.resolve(projectRoot, "modules/alert-prompt"), "@superset/tab-bar": path.resolve(projectRoot, "modules/tab-bar"), }; diff --git a/apps/mobile/modules/alert-prompt/expo-module.config.json b/apps/mobile/modules/alert-prompt/expo-module.config.json new file mode 100644 index 00000000000..2146f9c56dd --- /dev/null +++ b/apps/mobile/modules/alert-prompt/expo-module.config.json @@ -0,0 +1,4 @@ +{ + "platforms": ["apple"], + "apple": { "modules": ["AlertPromptModule"] } +} diff --git a/apps/mobile/modules/alert-prompt/ios/AlertPrompt.podspec b/apps/mobile/modules/alert-prompt/ios/AlertPrompt.podspec new file mode 100644 index 00000000000..76eff3c84a4 --- /dev/null +++ b/apps/mobile/modules/alert-prompt/ios/AlertPrompt.podspec @@ -0,0 +1,25 @@ +require 'json' + +package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json'))) + +Pod::Spec.new do |s| + s.name = 'AlertPrompt' + s.version = package['version'] + s.summary = 'Native text prompt alert for Superset' + s.description = 'UIAlertController prompt with text selection support' + s.license = 'MIT' + s.author = 'Superset' + s.homepage = 'https://superset.sh' + s.platforms = { :ios => '15.1' } + s.swift_version = '5.9' + s.source = { git: 'https://github.com/nicksupersetsh/superset.git' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES' + } + + s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}" +end diff --git a/apps/mobile/modules/alert-prompt/ios/AlertPromptModule.swift b/apps/mobile/modules/alert-prompt/ios/AlertPromptModule.swift new file mode 100644 index 00000000000..117d85a1e1b --- /dev/null +++ b/apps/mobile/modules/alert-prompt/ios/AlertPromptModule.swift @@ -0,0 +1,51 @@ +import ExpoModulesCore +import UIKit + +internal struct PromptOptions: Record { + @Field var title: String = "" + @Field var message: String? + @Field var defaultValue: String? + @Field var placeholder: String? + @Field var confirmText: String = "OK" + @Field var cancelText: String = "Cancel" + @Field var selectText: Bool = false +} + +public final class AlertPromptModule: Module { + public func definition() -> ModuleDefinition { + Name("AlertPrompt") + + AsyncFunction("prompt") { (options: PromptOptions, promise: Promise) in + guard let currentViewController = self.appContext?.utilities?.currentViewController() else { + promise.resolve(nil) + return + } + + let alert = UIAlertController( + title: options.title, + message: options.message, + preferredStyle: .alert + ) + alert.addTextField { textField in + textField.text = options.defaultValue + textField.placeholder = options.placeholder + } + alert.addAction(UIAlertAction(title: options.cancelText, style: .cancel) { _ in + promise.resolve(nil) + }) + alert.addAction(UIAlertAction(title: options.confirmText, style: .default) { _ in + promise.resolve(alert.textFields?.first?.text) + }) + + currentViewController.present(alert, animated: true) { + guard options.selectText, let textField = alert.textFields?.first else { + return + } + textField.selectedTextRange = textField.textRange( + from: textField.beginningOfDocument, + to: textField.endOfDocument + ) + } + }.runOnQueue(.main) + } +} diff --git a/apps/mobile/modules/alert-prompt/package.json b/apps/mobile/modules/alert-prompt/package.json new file mode 100644 index 00000000000..9b5f8714a62 --- /dev/null +++ b/apps/mobile/modules/alert-prompt/package.json @@ -0,0 +1,11 @@ +{ + "name": "@superset/alert-prompt", + "version": "0.0.1", + "main": "src/index.ts", + "private": true, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } +} diff --git a/apps/mobile/modules/alert-prompt/src/index.ts b/apps/mobile/modules/alert-prompt/src/index.ts new file mode 100644 index 00000000000..b651fd1509a --- /dev/null +++ b/apps/mobile/modules/alert-prompt/src/index.ts @@ -0,0 +1,17 @@ +import { requireNativeModule } from "expo"; + +export type PromptOptions = { + title: string; + message?: string; + defaultValue?: string; + placeholder?: string; + confirmText?: string; + cancelText?: string; + selectText?: boolean; +}; + +const AlertPromptModule = requireNativeModule("AlertPrompt"); + +export function prompt(options: PromptOptions): Promise { + return AlertPromptModule.prompt(options); +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 9af255611de..717e1128f7c 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -52,6 +52,7 @@ "@shikijs/themes": "4.2.0", "@superset/auth": "workspace:*", "@superset/db": "workspace:*", + "@superset/shared": "workspace:*", "@superset/trpc": "workspace:*", "@tanstack/db": "0.6.10", "@tanstack/electric-db-collection": "0.3.8", @@ -75,9 +76,12 @@ "expo-file-system": "56.0.7", "expo-glass-effect": "56.0.4", "expo-image": "56.0.10", + "expo-image-manipulator": "56.0.17", "expo-image-picker": "56.0.16", "expo-linking": "56.0.13", "expo-localization": "56.0.6", + "expo-media-library": "56.0.6", + "expo-modules-core": "56.0.15", "expo-network": "56.0.5", "expo-router": "56.2.9", "expo-secure-store": "56.0.4", @@ -106,7 +110,8 @@ "tailwindcss-animate": "1.0.7", "tokenlens": "1.3.1", "uniwind": "1.8.0", - "zod": "4.3.6" + "zod": "4.3.6", + "zustand": "5.0.12" }, "devDependencies": { "@babel/preset-typescript": "7.29.7", @@ -114,6 +119,8 @@ "@storybook/addon-ondevice-actions": "10.4.2", "@storybook/addon-ondevice-controls": "10.4.2", "@storybook/react-native": "10.4.2", + "@superset/host-service": "workspace:*", + "@trpc/server": "11.16.0", "@types/node": "24.12.0", "@types/react": "19.2.14", "babel-preset-expo": "56.0.11", diff --git a/apps/mobile/screens/(authenticated)/(home)/components/SheetCloseButton/SheetCloseButton.tsx b/apps/mobile/screens/(authenticated)/(home)/components/SheetCloseButton/SheetCloseButton.tsx new file mode 100644 index 00000000000..40d646a86fc --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/components/SheetCloseButton/SheetCloseButton.tsx @@ -0,0 +1,29 @@ +import Ionicons from "@expo/vector-icons/Ionicons"; +import { useRouter } from "expo-router"; +import { Pressable } from "react-native"; +import { useTheme } from "@/hooks/useTheme"; +import { cn } from "@/lib/utils"; + +export function SheetCloseButton({ + onPress, + className, +}: { + onPress?: () => void; + className?: string; +}) { + const theme = useTheme(); + const router = useRouter(); + return ( + router.back())} + > + + + ); +} diff --git a/apps/mobile/screens/(authenticated)/(home)/components/SheetCloseButton/index.ts b/apps/mobile/screens/(authenticated)/(home)/components/SheetCloseButton/index.ts new file mode 100644 index 00000000000..359a7ef1533 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/components/SheetCloseButton/index.ts @@ -0,0 +1 @@ +export { SheetCloseButton } from "./SheetCloseButton"; diff --git a/apps/mobile/screens/(authenticated)/(home)/filter/FilterScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/filter/FilterScreen.tsx new file mode 100644 index 00000000000..ffe732810a4 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/filter/FilterScreen.tsx @@ -0,0 +1,107 @@ +import Ionicons from "@expo/vector-icons/Ionicons"; +import { useLiveQuery } from "@tanstack/react-db"; +import { Stack, useRouter } from "expo-router"; +import { View } from "react-native"; +import { useTheme } from "@/hooks/useTheme"; +import { useSelectedHost } from "@/screens/(authenticated)/(home)/hooks/useSelectedHost"; +import { + SORT_OPTIONS, + useWorkspacesFilterStore, +} from "@/screens/(authenticated)/(home)/workspaces/stores/workspacesFilterStore"; +import { HostStatusDot } from "@/screens/(authenticated)/components/HostStatusDot"; +import { ListRow } from "@/screens/(authenticated)/components/ListRow"; +import { ListRowValue } from "@/screens/(authenticated)/components/ListRowValue"; +import { useCollections } from "@/screens/(authenticated)/providers/CollectionsProvider"; +import { ProjectAvatar } from "./components/ProjectAvatar"; + +export function FilterScreen() { + const router = useRouter(); + const theme = useTheme(); + const collections = useCollections(); + const projectFilter = useWorkspacesFilterStore( + (store) => store.projectFilter, + ); + const selectedHost = useSelectedHost(); + const sort = useWorkspacesFilterStore((store) => store.sort); + + const { data: projects } = useLiveQuery( + (q) => q.from({ v2Projects: collections.v2Projects }), + [collections], + ); + + const sortedProjects = [...(projects ?? [])].sort((a, b) => + a.name.localeCompare(b.name), + ); + const selectedProject = + sortedProjects.find((project) => project.id === projectFilter) ?? + sortedProjects[0]; + const sortLabel = + SORT_OPTIONS.find((option) => option.value === sort)?.label ?? ""; + + return ( + + + router.back()} /> + + + } + label="Project" + trailing={ + + ) : undefined + } + /> + } + onPress={() => router.push("/(authenticated)/(home)/filter/project")} + /> + + } + label="Host" + trailing={ + + ) : undefined + } + /> + } + onPress={() => router.push("/(authenticated)/(home)/filter/host")} + /> + + } + label="Sort" + trailing={} + onPress={() => router.push("/(authenticated)/(home)/filter/sort")} + isLast + /> + + ); +} diff --git a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/ProjectAvatar/ProjectAvatar.tsx b/apps/mobile/screens/(authenticated)/(home)/filter/components/ProjectAvatar/ProjectAvatar.tsx similarity index 100% rename from apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/ProjectAvatar/ProjectAvatar.tsx rename to apps/mobile/screens/(authenticated)/(home)/filter/components/ProjectAvatar/ProjectAvatar.tsx diff --git a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/ProjectAvatar/index.ts b/apps/mobile/screens/(authenticated)/(home)/filter/components/ProjectAvatar/index.ts similarity index 100% rename from apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/ProjectAvatar/index.ts rename to apps/mobile/screens/(authenticated)/(home)/filter/components/ProjectAvatar/index.ts diff --git a/apps/mobile/screens/(authenticated)/(home)/filter/host/HostFilterScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/filter/host/HostFilterScreen.tsx new file mode 100644 index 00000000000..047052ecd71 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/filter/host/HostFilterScreen.tsx @@ -0,0 +1,56 @@ +import { useLiveQuery } from "@tanstack/react-db"; +import { useRouter } from "expo-router"; +import { useMemo } from "react"; +import { ScrollView } from "react-native"; +import { useSelectedHost } from "@/screens/(authenticated)/(home)/hooks/useSelectedHost"; +import { useWorkspacesFilterStore } from "@/screens/(authenticated)/(home)/workspaces/stores/workspacesFilterStore"; +import { HostStatusDot } from "@/screens/(authenticated)/components/HostStatusDot"; +import { ListRow } from "@/screens/(authenticated)/components/ListRow"; +import { ListRowCheck } from "@/screens/(authenticated)/components/ListRowCheck"; +import { useCollections } from "@/screens/(authenticated)/providers/CollectionsProvider"; + +export function HostFilterScreen() { + const router = useRouter(); + const collections = useCollections(); + const selectedHost = useSelectedHost(); + const setHostFilter = useWorkspacesFilterStore( + (store) => store.setHostFilter, + ); + + const { data: hosts } = useLiveQuery( + (q) => q.from({ v2Hosts: collections.v2Hosts }), + [collections], + ); + + const sortedHosts = useMemo( + () => [...(hosts ?? [])].sort((a, b) => a.name.localeCompare(b.name)), + [hosts], + ); + + const selectHost = (machineId: string) => { + setHostFilter(machineId); + router.back(); + }; + + return ( + + {sortedHosts.map((host, index) => ( + } + label={host.name} + trailing={ + + } + onPress={() => selectHost(host.machineId)} + isLast={index === sortedHosts.length - 1} + /> + ))} + + ); +} diff --git a/apps/mobile/screens/(authenticated)/(home)/filter/host/index.ts b/apps/mobile/screens/(authenticated)/(home)/filter/host/index.ts new file mode 100644 index 00000000000..7e3e6a8a3d3 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/filter/host/index.ts @@ -0,0 +1 @@ +export { HostFilterScreen } from "./HostFilterScreen"; diff --git a/apps/mobile/screens/(authenticated)/(home)/filter/index.ts b/apps/mobile/screens/(authenticated)/(home)/filter/index.ts new file mode 100644 index 00000000000..1884229e408 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/filter/index.ts @@ -0,0 +1 @@ +export { FilterScreen } from "./FilterScreen"; diff --git a/apps/mobile/screens/(authenticated)/(home)/filter/project/ProjectFilterScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/filter/project/ProjectFilterScreen.tsx new file mode 100644 index 00000000000..48335a41d5c --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/filter/project/ProjectFilterScreen.tsx @@ -0,0 +1,86 @@ +import { useLiveQuery } from "@tanstack/react-db"; +import { useRouter } from "expo-router"; +import { useMemo } from "react"; +import { ScrollView, Text } from "react-native"; +import { useHostWorkspaces } from "@/hooks/useHostWorkspaces"; +import { useTheme } from "@/hooks/useTheme"; +import { useSelectedHost } from "@/screens/(authenticated)/(home)/hooks/useSelectedHost"; +import { useWorkspacesFilterStore } from "@/screens/(authenticated)/(home)/workspaces/stores/workspacesFilterStore"; +import { ListRow } from "@/screens/(authenticated)/components/ListRow"; +import { ListRowCheck } from "@/screens/(authenticated)/components/ListRowCheck"; +import { useCollections } from "@/screens/(authenticated)/providers/CollectionsProvider"; +import { ProjectAvatar } from "../components/ProjectAvatar"; + +export function ProjectFilterScreen() { + const router = useRouter(); + const theme = useTheme(); + const collections = useCollections(); + const selectedHost = useSelectedHost(); + const { workspaces } = useHostWorkspaces(selectedHost); + const projectFilter = useWorkspacesFilterStore( + (store) => store.projectFilter, + ); + const setProjectFilter = useWorkspacesFilterStore( + (store) => store.setProjectFilter, + ); + + const { data: projects } = useLiveQuery( + (q) => q.from({ v2Projects: collections.v2Projects }), + [collections], + ); + + const sortedProjects = useMemo( + () => [...(projects ?? [])].sort((a, b) => a.name.localeCompare(b.name)), + [projects], + ); + + const workspaceCounts = useMemo(() => { + const counts = new Map(); + for (const workspace of workspaces) { + counts.set( + workspace.projectId, + (counts.get(workspace.projectId) ?? 0) + 1, + ); + } + return counts; + }, [workspaces]); + + const selectedProjectId = projectFilter ?? sortedProjects[0]?.id ?? null; + + return ( + + {sortedProjects.map((project, index) => ( + + } + label={project.name} + trailing={ + <> + + {workspaceCounts.get(project.id) ?? 0} + + + + } + onPress={() => { + setProjectFilter(project.id); + router.back(); + }} + isLast={index === sortedProjects.length - 1} + /> + ))} + + ); +} diff --git a/apps/mobile/screens/(authenticated)/(home)/filter/project/index.ts b/apps/mobile/screens/(authenticated)/(home)/filter/project/index.ts new file mode 100644 index 00000000000..b4b861634f3 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/filter/project/index.ts @@ -0,0 +1 @@ +export { ProjectFilterScreen } from "./ProjectFilterScreen"; diff --git a/apps/mobile/screens/(authenticated)/(home)/filter/sort/SortFilterScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/filter/sort/SortFilterScreen.tsx new file mode 100644 index 00000000000..bcf829340dd --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/filter/sort/SortFilterScreen.tsx @@ -0,0 +1,31 @@ +import { useRouter } from "expo-router"; +import { View } from "react-native"; +import { + SORT_OPTIONS, + useWorkspacesFilterStore, +} from "@/screens/(authenticated)/(home)/workspaces/stores/workspacesFilterStore"; +import { ListRow } from "@/screens/(authenticated)/components/ListRow"; +import { ListRowCheck } from "@/screens/(authenticated)/components/ListRowCheck"; + +export function SortFilterScreen() { + const router = useRouter(); + const sort = useWorkspacesFilterStore((store) => store.sort); + const setSort = useWorkspacesFilterStore((store) => store.setSort); + + return ( + + {SORT_OPTIONS.map((option, index) => ( + } + onPress={() => { + setSort(option.value); + router.back(); + }} + isLast={index === SORT_OPTIONS.length - 1} + /> + ))} + + ); +} diff --git a/apps/mobile/screens/(authenticated)/(home)/filter/sort/index.ts b/apps/mobile/screens/(authenticated)/(home)/filter/sort/index.ts new file mode 100644 index 00000000000..98a7635eed7 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/filter/sort/index.ts @@ -0,0 +1 @@ +export { SortFilterScreen } from "./SortFilterScreen"; diff --git a/apps/mobile/screens/(authenticated)/(home)/hooks/useSelectedHost/index.ts b/apps/mobile/screens/(authenticated)/(home)/hooks/useSelectedHost/index.ts new file mode 100644 index 00000000000..317aa36725b --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/hooks/useSelectedHost/index.ts @@ -0,0 +1 @@ +export { useSelectedHost } from "./useSelectedHost"; diff --git a/apps/mobile/screens/(authenticated)/(home)/hooks/useSelectedHost/useSelectedHost.ts b/apps/mobile/screens/(authenticated)/(home)/hooks/useSelectedHost/useSelectedHost.ts new file mode 100644 index 00000000000..e487611d197 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/hooks/useSelectedHost/useSelectedHost.ts @@ -0,0 +1,31 @@ +import type { SelectV2Host } from "@superset/db/schema"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useMemo } from "react"; +import { useWorkspacesFilterStore } from "@/screens/(authenticated)/(home)/workspaces/stores/workspacesFilterStore"; +import { useCollections } from "@/screens/(authenticated)/providers/CollectionsProvider"; + +/** + * The list view is always scoped to one host: the explicit filter pick if + * that host still exists, else the first online host, else the first host. + */ +export function useSelectedHost(): SelectV2Host | null { + const collections = useCollections(); + const hostFilter = useWorkspacesFilterStore((store) => store.hostFilter); + + const { data: hosts } = useLiveQuery( + (q) => q.from({ v2Hosts: collections.v2Hosts }), + [collections], + ); + + return useMemo(() => { + const sorted = [...(hosts ?? [])].sort((a, b) => + a.name.localeCompare(b.name), + ); + return ( + sorted.find((host) => host.machineId === hostFilter) ?? + sorted.find((host) => host.isOnline) ?? + sorted[0] ?? + null + ); + }, [hosts, hostFilter]); +} diff --git a/apps/mobile/screens/(authenticated)/(home)/new-chat/branch/BranchPickerScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/new-chat/branch/BranchPickerScreen.tsx new file mode 100644 index 00000000000..79827d06337 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/new-chat/branch/BranchPickerScreen.tsx @@ -0,0 +1,164 @@ +import Ionicons from "@expo/vector-icons/Ionicons"; +import { useQuery } from "@tanstack/react-query"; +import { Stack, useRouter } from "expo-router"; +import { useMemo, useState } from "react"; +import { Pressable, ScrollView, View } from "react-native"; +import { Input } from "@/components/ui/input"; +import { Spinner } from "@/components/ui/spinner"; +import { Text } from "@/components/ui/text"; +import { useTheme } from "@/hooks/useTheme"; +import { getHostServiceClientByUrl } from "@/lib/host-service/client"; +import { useNewChatTargets } from "@/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/hooks/useNewChatTargets"; +import { useNewChatPreferencesStore } from "@/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/stores/newChatPreferencesStore"; + +function BranchRow({ + name, + isSelected, + onPress, +}: { + name: string; + isSelected: boolean; + onPress: () => void; +}) { + const theme = useTheme(); + return ( + + + {name} + + {isSelected ? ( + + ) : null} + + ); +} + +export function BranchPickerScreen() { + const router = useRouter(); + const theme = useTheme(); + const [query, setQuery] = useState(""); + const { targets, defaultTarget } = useNewChatTargets(); + const targetKey = useNewChatPreferencesStore((state) => state.targetKey); + const baseBranch = useNewChatPreferencesStore((state) => state.baseBranch); + const setBaseBranch = useNewChatPreferencesStore( + (state) => state.setBaseBranch, + ); + + const selectedTarget = + targets.find((target) => target.key === targetKey) ?? defaultTarget; + const hostUrl = selectedTarget?.hostUrl ?? null; + const projectId = selectedTarget?.projectId ?? null; + + const trimmedQuery = query.trim(); + const { data, isLoading } = useQuery({ + queryKey: ["host-service", "branches", hostUrl, projectId, trimmedQuery], + enabled: hostUrl !== null && projectId !== null, + placeholderData: (previous) => previous, + networkMode: "always" as const, + queryFn: async () => { + if (!hostUrl || !projectId) return null; + return getHostServiceClientByUrl( + hostUrl, + ).workspaceCreation.searchBranches.query({ + projectId, + query: trimmedQuery || undefined, + limit: 50, + refresh: trimmedQuery === "", + }); + }, + }); + + const defaultBranch = data?.defaultBranch ?? null; + const branches = useMemo( + () => (data?.items ?? []).filter((branch) => branch.name !== defaultBranch), + [data, defaultBranch], + ); + + const selectAndClose = (branch: string | null) => { + setBaseBranch(branch); + router.back(); + }; + + return ( + <> + + router.back()} /> + + {/* The formSheet's content wrapper special-cases its direct subviews: + it expects [header, ScrollView] and sizes the ScrollView to the + remainder (react-native-screens RNSScreenContentWrapper). Any + other shape gets the ScrollView pinned over the whole sheet. The + header needs collapsable={false} so RN view flattening doesn't + remove it from the native hierarchy. */} + + + + + + + + + + {defaultBranch ? ( + <> + + Default + + selectAndClose(null)} + /> + + ) : null} + {branches.length > 0 ? ( + + {trimmedQuery ? "Branches" : "Recents"} + + ) : null} + {branches.map((branch) => ( + selectAndClose(branch.name)} + /> + ))} + {isLoading && !data ? ( + + + + ) : null} + {!isLoading && !defaultBranch && branches.length === 0 ? ( + + No branches found + + ) : null} + + + ); +} diff --git a/apps/mobile/screens/(authenticated)/(home)/new-chat/branch/index.ts b/apps/mobile/screens/(authenticated)/(home)/new-chat/branch/index.ts new file mode 100644 index 00000000000..a71471e3058 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/new-chat/branch/index.ts @@ -0,0 +1 @@ +export { BranchPickerScreen } from "./BranchPickerScreen"; diff --git a/apps/mobile/screens/(authenticated)/(home)/new-chat/model/ModelPickerScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/new-chat/model/ModelPickerScreen.tsx new file mode 100644 index 00000000000..6aaf2a5cb33 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/new-chat/model/ModelPickerScreen.tsx @@ -0,0 +1,55 @@ +import Ionicons from "@expo/vector-icons/Ionicons"; +import { SUPERSET_CHAT_MODELS } from "@superset/shared/agent-models"; +import { Stack, useRouter } from "expo-router"; +import { Pressable, ScrollView } from "react-native"; +import { Text } from "@/components/ui/text"; +import { useTheme } from "@/hooks/useTheme"; +import { useNewChatPreferencesStore } from "@/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/stores/newChatPreferencesStore"; + +export function ModelPickerScreen() { + const router = useRouter(); + const theme = useTheme(); + const modelId = useNewChatPreferencesStore((state) => state.modelId); + const setModelId = useNewChatPreferencesStore((state) => state.setModelId); + + return ( + + + router.back()} /> + + {SUPERSET_CHAT_MODELS.map((model) => { + const isSelected = model.id === modelId; + return ( + { + setModelId(model.id); + router.back(); + }} + className="flex-row items-center gap-2.5 py-2.5" + > + + {model.label} + + + {model.provider} + + {isSelected ? ( + + ) : null} + + ); + })} + + ); +} diff --git a/apps/mobile/screens/(authenticated)/(home)/new-chat/model/index.ts b/apps/mobile/screens/(authenticated)/(home)/new-chat/model/index.ts new file mode 100644 index 00000000000..014e402c9a6 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/new-chat/model/index.ts @@ -0,0 +1 @@ +export { ModelPickerScreen } from "./ModelPickerScreen"; diff --git a/apps/mobile/screens/(authenticated)/(home)/new-chat/project/ProjectPickerScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/new-chat/project/ProjectPickerScreen.tsx new file mode 100644 index 00000000000..2da8ac85102 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/new-chat/project/ProjectPickerScreen.tsx @@ -0,0 +1,82 @@ +import Ionicons from "@expo/vector-icons/Ionicons"; +import { Stack, useRouter } from "expo-router"; +import { Pressable, ScrollView, View } from "react-native"; +import { Text } from "@/components/ui/text"; +import { useTheme } from "@/hooks/useTheme"; +import { ProjectAvatar } from "@/screens/(authenticated)/(home)/filter/components/ProjectAvatar"; +import { useNewChatTargets } from "@/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/hooks/useNewChatTargets"; +import { useNewChatPreferencesStore } from "@/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/stores/newChatPreferencesStore"; + +export function ProjectPickerScreen() { + const router = useRouter(); + const theme = useTheme(); + const { targets, defaultTarget } = useNewChatTargets(); + const targetKey = useNewChatPreferencesStore((state) => state.targetKey); + const setTargetKey = useNewChatPreferencesStore( + (state) => state.setTargetKey, + ); + + const selectedKey = + targets.find((target) => target.key === targetKey)?.key ?? + defaultTarget?.key ?? + null; + + return ( + + + router.back()} /> + + {targets.length === 0 ? ( + + No projects on an online host + + ) : null} + {targets.map((target) => { + const isSelected = target.key === selectedKey; + return ( + { + setTargetKey(target.key); + router.back(); + }} + className="flex-row items-center gap-2.5 py-2.5" + > + + + + {target.projectName} + + + {target.hostName} + + + {isSelected ? ( + + ) : null} + + ); + })} + + ); +} diff --git a/apps/mobile/screens/(authenticated)/(home)/new-chat/project/index.ts b/apps/mobile/screens/(authenticated)/(home)/new-chat/project/index.ts new file mode 100644 index 00000000000..d5882c45886 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/new-chat/project/index.ts @@ -0,0 +1 @@ +export { ProjectPickerScreen } from "./ProjectPickerScreen"; diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/WorkspacesScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/WorkspacesScreen.tsx new file mode 100644 index 00000000000..614d569b858 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/WorkspacesScreen.tsx @@ -0,0 +1,286 @@ +import { LegendList } from "@legendapp/list/react-native"; +import type { SelectGithubPullRequest } from "@superset/db/schema"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useQueryClient } from "@tanstack/react-query"; +import { compareDesc, isAfter } from "date-fns"; +import { Stack, useFocusEffect, useRouter } from "expo-router"; +import { useCallback, useMemo, useState } from "react"; +import { RefreshControl, useWindowDimensions, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Text } from "@/components/ui/text"; +import { + type HostWorkspaceItem, + useHostWorkspaces, +} from "@/hooks/useHostWorkspaces"; +import { THEME } from "@/lib/theme"; +import { useSelectedHost } from "@/screens/(authenticated)/(home)/hooks/useSelectedHost"; +import { useOrganizations } from "@/screens/(authenticated)/hooks/useOrganizations"; +import { useCollections } from "@/screens/(authenticated)/providers/CollectionsProvider"; +import { HostOfflineView } from "./components/HostOfflineView"; +import { NewChatWidget } from "./components/NewChatWidget"; +import { OrganizationHeaderButton } from "./components/OrganizationHeaderButton"; +import { OrganizationSwitcherSheet } from "./components/OrganizationSwitcherSheet"; +import { prStateFor, WorkspaceRow } from "./components/WorkspaceRow"; +import { useVisibleDiffStats } from "./hooks/useVisibleDiffStats"; +import { useWorkspacesFilterStore } from "./stores/workspacesFilterStore"; + +const VIEWABILITY_CONFIG = { + itemVisiblePercentThreshold: 50, + minimumViewTime: 250, +}; + +const MAX_VISIBLE_DIFF_STATS = 20; + +const NAVIGATION_BAR_HEIGHT = 44; + +export function WorkspacesScreen() { + const router = useRouter(); + const [sheetOpen, setSheetOpen] = useState(false); + const projectFilter = useWorkspacesFilterStore( + (store) => store.projectFilter, + ); + const sort = useWorkspacesFilterStore((store) => store.sort); + const [searchQuery, setSearchQuery] = useState(""); + const [visibleIds, setVisibleIds] = useState([]); + const [refreshing, setRefreshing] = useState(false); + const { width, height: windowHeight } = useWindowDimensions(); + const insets = useSafeAreaInsets(); + const collections = useCollections(); + const queryClient = useQueryClient(); + const { + organizations, + activeOrganization, + activeOrganizationId, + switchOrganization, + } = useOrganizations(); + + const selectedHost = useSelectedHost(); + const { workspaces, isReady, cache } = useHostWorkspaces(selectedHost); + + const { data: projects } = useLiveQuery( + (q) => q.from({ v2Projects: collections.v2Projects }), + [collections], + ); + const { data: pullRequests } = useLiveQuery( + (q) => q.from({ githubPullRequests: collections.githubPullRequests }), + [collections], + ); + + const sortedProjects = useMemo( + () => [...(projects ?? [])].sort((a, b) => a.name.localeCompare(b.name)), + [projects], + ); + + const selectedProjectId = projectFilter ?? sortedProjects[0]?.id ?? null; + + const projectNamesById = useMemo( + () => + new Map((projects ?? []).map((project) => [project.id, project.name])), + [projects], + ); + + const visibleWorkspaces = useMemo(() => { + const needle = searchQuery.trim().toLowerCase(); + const matches = needle + ? workspaces.filter( + (workspace) => + workspace.name.toLowerCase().includes(needle) || + workspace.branch.toLowerCase().includes(needle) || + (projectNamesById.get(workspace.projectId) ?? "") + .toLowerCase() + .includes(needle), + ) + : workspaces.filter( + (workspace) => + workspace.projectId === selectedProjectId && + workspace.hostId === selectedHost?.machineId, + ); + return matches.sort((a, b) => compareDesc(a[sort], b[sort])); + }, [ + workspaces, + selectedProjectId, + selectedHost, + sort, + searchQuery, + projectNamesById, + ]); + + const workspacesById = useMemo( + () => new Map(workspaces.map((workspace) => [workspace.id, workspace])), + [workspaces], + ); + + const pullRequestsByRepoBranch = useMemo(() => { + const rank = { closed: 3, draft: 1, merged: 2, open: 0 } as const; + const byRepoBranch = new Map(); + for (const pullRequest of pullRequests ?? []) { + const key = `${pullRequest.repositoryId}::${pullRequest.headBranch}`; + const existing = byRepoBranch.get(key); + if (!existing) { + byRepoBranch.set(key, pullRequest); + continue; + } + const cmp = rank[prStateFor(pullRequest)] - rank[prStateFor(existing)]; + if ( + cmp < 0 || + (cmp === 0 && isAfter(pullRequest.updatedAt, existing.updatedAt)) + ) { + byRepoBranch.set(key, pullRequest); + } + } + return byRepoBranch; + }, [pullRequests]); + + const diffStats = useVisibleDiffStats({ + visibleIds, + workspacesById, + resolveHostUrl: cache.resolveHostUrl, + }); + + const onViewableItemsChanged = useCallback( + ({ + viewableItems, + }: { + viewableItems: Array<{ item: HostWorkspaceItem; isViewable: boolean }>; + }) => { + setVisibleIds( + viewableItems + .filter((viewable) => viewable.isViewable) + .slice(0, MAX_VISIBLE_DIFF_STATS) + .map((viewable) => viewable.item.id), + ); + }, + [], + ); + + const refreshHostData = useCallback(() => { + void queryClient.invalidateQueries({ + queryKey: ["host-service", "workspaces", "list"], + }); + void queryClient.invalidateQueries({ queryKey: ["diff-stats"] }); + }, [queryClient]); + + useFocusEffect(refreshHostData); + + const onRefresh = useCallback(async () => { + setRefreshing(true); + await queryClient + .refetchQueries({ queryKey: ["host-service", "workspaces", "list"] }) + .catch(() => {}); + void queryClient.invalidateQueries({ queryKey: ["diff-stats"] }); + setRefreshing(false); + }, [queryClient]); + + const repositoryIdsByProject = useMemo( + () => + new Map( + (projects ?? []).map((project) => [ + project.id, + project.githubRepositoryId, + ]), + ), + [projects], + ); + + const renderItem = useCallback( + ({ item }: { item: HostWorkspaceItem }) => { + const repositoryId = repositoryIdsByProject.get(item.projectId); + return ( + + ); + }, + [pullRequestsByRepoBranch, repositoryIdsByProject, diffStats, cache], + ); + + const handleSwitchOrganization = (organizationId: string) => { + setSheetOpen(false); + switchOrganization(organizationId); + }; + + return ( + <> + setSheetOpen(true)} + /> + setSearchQuery(event.nativeEvent.text)} + onCancelButtonPress={() => setSearchQuery("")} + /> + + router.push("/(authenticated)/(home)/filter")} + /> + + {selectedHost && !selectedHost.isOnline ? ( + + + + ) : ( + item.id} + renderItem={renderItem} + viewabilityConfig={VIEWABILITY_CONFIG} + onViewableItemsChanged={onViewableItemsChanged} + refreshControl={ + + } + ListEmptyComponent={ + isReady ? ( + + + {searchQuery.trim() + ? "No workspaces match your search" + : "No workspaces in this project yet"} + + + ) : null + } + /> + )} + + + + ); +} diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/HostOfflineView/HostOfflineView.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/HostOfflineView/HostOfflineView.tsx new file mode 100644 index 00000000000..05e0645c2b0 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/HostOfflineView/HostOfflineView.tsx @@ -0,0 +1,31 @@ +import { useRouter } from "expo-router"; +import { CloudOff } from "lucide-react-native"; +import { View } from "react-native"; +import { Button } from "@/components/ui/button"; +import { Icon } from "@/components/ui/icon"; +import { Text } from "@/components/ui/text"; + +export function HostOfflineView({ hostName }: { hostName: string }) { + const router = useRouter(); + return ( + + + + {hostName} is offline + + Its workspaces will appear when it reconnects. + + + + + ); +} diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/HostOfflineView/index.ts b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/HostOfflineView/index.ts new file mode 100644 index 00000000000..5575c05fea2 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/HostOfflineView/index.ts @@ -0,0 +1 @@ +export { HostOfflineView } from "./HostOfflineView"; diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/NewChatWidget.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/NewChatWidget.tsx new file mode 100644 index 00000000000..0b75a334202 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/NewChatWidget.tsx @@ -0,0 +1,219 @@ +import { SUPERSET_CHAT_MODELS } from "@superset/shared/agent-models"; +import { useQuery } from "@tanstack/react-query"; +import { useRouter } from "expo-router"; +import { ChevronDownIcon, PlusIcon } from "lucide-react-native"; +import { useState } from "react"; +import { + Alert, + KeyboardAvoidingView, + Pressable, + StyleSheet, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { + PromptInput, + PromptInputAttachments, + PromptInputBody, + PromptInputButton, + PromptInputFooter, + PromptInputHeader, + type PromptInputMessage, + PromptInputProvider, + PromptInputSubmit, + PromptInputTextarea, + PromptInputTools, + usePromptInputController, +} from "@/components/ai-elements/prompt-input"; +import { Icon } from "@/components/ui/icon"; +import { Text } from "@/components/ui/text"; +import type { HostWorkspaceItem } from "@/hooks/useHostWorkspaces"; +import { getHostServiceClientByUrl } from "@/lib/host-service/client"; +import { ProjectAvatar } from "@/screens/(authenticated)/(home)/filter/components/ProjectAvatar"; +import { ContextSheet } from "./components/ContextSheet"; +import { useCreateChatWorkspace } from "./hooks/useCreateChatWorkspace"; +import { useNewChatTargets } from "./hooks/useNewChatTargets"; +import { useNewChatPreferencesStore } from "./stores/newChatPreferencesStore"; + +export function NewChatWidget({ + workspaces, +}: { + workspaces: HostWorkspaceItem[]; +}) { + return ( + + + + + + ); +} + +function NewChatWidgetInner({ + workspaces, +}: { + workspaces: HostWorkspaceItem[]; +}) { + const router = useRouter(); + const insets = useSafeAreaInsets(); + const controller = usePromptInputController(); + + const [contextSheetOpen, setContextSheetOpen] = useState(false); + const [focused, setFocused] = useState(false); + + const modelId = useNewChatPreferencesStore((state) => state.modelId); + const targetKey = useNewChatPreferencesStore((state) => state.targetKey); + const baseBranch = useNewChatPreferencesStore((state) => state.baseBranch); + const setBaseBranch = useNewChatPreferencesStore( + (state) => state.setBaseBranch, + ); + + const { targets, defaultTarget } = useNewChatTargets(workspaces); + const selectedTarget = + targets.find((target) => target.key === targetKey) ?? defaultTarget; + + const { data: branchData } = useQuery({ + queryKey: [ + "host-service", + "branches", + selectedTarget?.hostUrl ?? null, + selectedTarget?.projectId ?? null, + "", + ], + enabled: selectedTarget !== null, + networkMode: "always" as const, + queryFn: async () => { + if (!selectedTarget) return null; + return getHostServiceClientByUrl( + selectedTarget.hostUrl, + ).workspaceCreation.searchBranches.query({ + projectId: selectedTarget.projectId, + limit: 50, + refresh: true, + }); + }, + }); + + const createChatWorkspace = useCreateChatWorkspace(); + const selectedModel = SUPERSET_CHAT_MODELS.find( + (model) => model.id === modelId, + ); + const branchLabel = baseBranch ?? branchData?.defaultBranch ?? "default"; + const expanded = + focused || + controller.textInput.value.trim().length > 0 || + controller.attachments.attachments.length > 0; + + const handleSubmit = (message: PromptInputMessage) => { + if (!selectedTarget) { + Alert.alert("No project on an online host"); + return Promise.reject(new Error("No target")); + } + return createChatWorkspace + .mutateAsync({ + target: selectedTarget, + baseBranch, + modelId, + message, + }) + .then(() => setBaseBranch(null)); + }; + + return ( + <> + + + + {expanded ? ( + + + router.push("/(authenticated)/(home)/new-chat/project") + } + > + + + {selectedTarget?.projectName ?? "No project"} + + + + router.push("/(authenticated)/(home)/new-chat/branch") + } + > + + {branchLabel} + + + + + ) : null} + + + setFocused(false)} + onFocus={() => setFocused(true)} + /> + + {expanded ? ( + + + setContextSheetOpen(true)} + > + + + + router.push("/(authenticated)/(home)/new-chat/model") + } + > + + {selectedModel?.label ?? "Model"} + + + + + + + ) : null} + + + + + + ); +} diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/ContextSheet/ContextSheet.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/ContextSheet/ContextSheet.tsx new file mode 100644 index 00000000000..0b107937702 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/ContextSheet/ContextSheet.tsx @@ -0,0 +1,292 @@ +import { BottomSheet, Group, Host, RNHostView } from "@expo/ui/swift-ui"; +import { + background, + environment, + frame, + presentationBackground, + presentationDetents, + presentationDragIndicator, +} from "@expo/ui/swift-ui/modifiers"; +import Ionicons from "@expo/vector-icons/Ionicons"; +import { manipulateAsync, SaveFormat } from "expo-image-manipulator"; +import * as ImagePicker from "expo-image-picker"; +import * as MediaLibrary from "expo-media-library/legacy"; +import { useCallback, useState } from "react"; +import { Alert, Pressable, useWindowDimensions, View } from "react-native"; +import { usePromptInputAttachments } from "@/components/ai-elements/prompt-input"; +import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/spinner"; +import { Text } from "@/components/ui/text"; +import { useTheme } from "@/hooks/useTheme"; +import { SheetCloseButton } from "@/screens/(authenticated)/(home)/components/SheetCloseButton"; +import { hslToHex } from "../../../../utils/hslToHex"; +import { PhotoCarousel } from "./components/PhotoCarousel"; +import { ScreenshotGrid } from "./components/ScreenshotGrid"; + +// Pickers present their own view controller; wait for the sheet's +// dismissal animation or iOS drops the second presentation. +const SHEET_DISMISS_DELAY_MS = 400; +const LARGE_DETENT_FRACTION = 0.88; + +type SheetView = "main" | "screenshots"; +type SheetDetent = "medium" | "large"; + +export function ContextSheet({ + isPresented, + onIsPresentedChange, +}: { + isPresented: boolean; + onIsPresentedChange: (value: boolean) => void; +}) { + const theme = useTheme(); + const { width, height } = useWindowDimensions(); + const attachments = usePromptInputAttachments(); + const [selected, setSelected] = useState([]); + const [adding, setAdding] = useState(false); + const [view, setView] = useState("main"); + const [detent, setDetent] = useState("medium"); + + const handlePresentedChange = useCallback( + (value: boolean) => { + if (!value) { + setSelected([]); + setView("main"); + setDetent("medium"); + } + onIsPresentedChange(value); + }, + [onIsPresentedChange], + ); + + const toggleAsset = useCallback((asset: MediaLibrary.Asset) => { + setSelected((previous) => + previous.some((entry) => entry.id === asset.id) + ? previous.filter((entry) => entry.id !== asset.id) + : [...previous, asset], + ); + }, []); + + const runAfterDismiss = (action: () => void) => { + handlePresentedChange(false); + setTimeout(action, SHEET_DISMISS_DELAY_MS); + }; + + const openScreenshots = () => { + setView("screenshots"); + setDetent("large"); + }; + + const openCamera = async () => { + const permission = await ImagePicker.requestCameraPermissionsAsync(); + if (!permission.granted) { + Alert.alert("Camera access is not allowed"); + return; + } + const result = await ImagePicker.launchCameraAsync({ quality: 0.8 }); + if (result.canceled) return; + attachments.add( + result.assets.map((asset) => ({ + mediaType: asset.mimeType, + name: asset.fileName ?? undefined, + size: asset.fileSize, + type: "image" as const, + uri: asset.uri, + })), + ); + }; + + const handleAddSelected = async () => { + setAdding(true); + try { + const items = await Promise.all( + selected.map(async (asset) => { + const info = await MediaLibrary.getAssetInfoAsync(asset); + // Library assets are often HEIC, which the agent API + // rejects — transcode to JPEG. + const converted = await manipulateAsync( + info.localUri ?? asset.uri, + [], + { compress: 0.8, format: SaveFormat.JPEG }, + ); + return { + mediaType: "image/jpeg", + name: asset.filename, + type: "image" as const, + uri: converted.uri, + }; + }), + ); + attachments.add(items); + handlePresentedChange(false); + } catch (error) { + Alert.alert( + "Could not add photos", + error instanceof Error ? error.message : String(error), + ); + } finally { + setAdding(false); + } + }; + + const mainRows = [ + { + icon: "images-outline" as const, + label: "Photos", + onPress: () => runAfterDismiss(() => void attachments.openImagePicker()), + }, + { + icon: "scan-outline" as const, + label: "Screenshots", + onPress: openScreenshots, + showsChevron: true, + }, + { + icon: "camera-outline" as const, + label: "Camera", + onPress: () => runAfterDismiss(() => void openCamera()), + }, + { + icon: "document-outline" as const, + label: "Files", + onPress: () => runAfterDismiss(() => void attachments.openFilePicker()), + }, + ]; + + const addButton = + selected.length > 0 ? ( + + + + ) : null; + + return ( + + + { + if (selection === "medium" || selection === "large") { + setDetent(selection); + } + }, + }), + presentationDragIndicator("visible"), + background(theme.background), + presentationBackground(hslToHex(theme.background)), + frame({ maxHeight: 10_000, alignment: "top" }), + ]} + > + + {view === "screenshots" ? ( + + + setView("main")} + > + + + + Screenshots + + + + {addButton} + + ) : ( + + + + handlePresentedChange(false)} + /> + + + Context + + + + + + Add + + {mainRows.map((row) => ( + + + + {row.label} + + {row.showsChevron ? ( + + ) : null} + + ))} + + {addButton} + + )} + + + + + ); +} diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/ContextSheet/components/PhotoCarousel/PhotoCarousel.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/ContextSheet/components/PhotoCarousel/PhotoCarousel.tsx new file mode 100644 index 00000000000..256603c8245 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/ContextSheet/components/PhotoCarousel/PhotoCarousel.tsx @@ -0,0 +1,127 @@ +import { useQuery } from "@tanstack/react-query"; +import { Image } from "expo-image"; +import * as MediaLibrary from "expo-media-library/legacy"; +import { useEffect, useState } from "react"; +import { Linking, Pressable, ScrollView, View } from "react-native"; +import { Button } from "@/components/ui/button"; +import { Text } from "@/components/ui/text"; + +const RECENT_PHOTOS_LIMIT = 30; +const THUMBNAIL_SIZE = 96; + +export function PhotoCarousel({ + active, + selected, + onToggle, +}: { + active: boolean; + selected: MediaLibrary.Asset[]; + onToggle: (asset: MediaLibrary.Asset) => void; +}) { + const [permission, setPermission] = + useState(null); + + useEffect(() => { + if (!active) return; + void MediaLibrary.getPermissionsAsync().then(setPermission); + }, [active]); + + const granted = permission?.granted ?? false; + + const { data: assets } = useQuery({ + queryKey: ["media-library", "recent-photos"], + enabled: active && granted, + staleTime: 30_000, + queryFn: async () => { + const page = await MediaLibrary.getAssetsAsync({ + first: RECENT_PHOTOS_LIMIT, + mediaType: "photo", + sortBy: [["creationTime", false]], + }); + return page.assets; + }, + }); + + if (!permission) { + return ; + } + + if (!granted) { + const mustUseSettings = !permission.canAskAgain; + return ( + + + Attach images from your photo library. + + + + ); + } + + return ( + + {(assets ?? []).map((asset) => { + const selectionIndex = selected.findIndex( + (entry) => entry.id === asset.id, + ); + const isSelected = selectionIndex >= 0; + return ( + onToggle(asset)} + > + + {isSelected ? ( + + + + {selectionIndex + 1} + + + + ) : null} + + ); + })} + {granted && (assets ?? []).length === 0 ? ( + + + No photos in your library + + + ) : null} + + ); +} diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/ContextSheet/components/PhotoCarousel/index.ts b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/ContextSheet/components/PhotoCarousel/index.ts new file mode 100644 index 00000000000..4ed8e8948f7 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/ContextSheet/components/PhotoCarousel/index.ts @@ -0,0 +1 @@ +export { PhotoCarousel } from "./PhotoCarousel"; diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/ContextSheet/components/ScreenshotGrid/ScreenshotGrid.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/ContextSheet/components/ScreenshotGrid/ScreenshotGrid.tsx new file mode 100644 index 00000000000..a8266c69f79 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/ContextSheet/components/ScreenshotGrid/ScreenshotGrid.tsx @@ -0,0 +1,133 @@ +import { useQuery } from "@tanstack/react-query"; +import { Image } from "expo-image"; +import * as MediaLibrary from "expo-media-library/legacy"; +import { useEffect, useState } from "react"; +import { Pressable, ScrollView, useWindowDimensions, View } from "react-native"; +import { Spinner } from "@/components/ui/spinner"; +import { Text } from "@/components/ui/text"; +import { useTheme } from "@/hooks/useTheme"; + +const SCREENSHOTS_LIMIT = 100; +const GRID_COLUMNS = 4; +const GRID_GAP = 6; +const GRID_HORIZONTAL_PADDING = 20; + +export function ScreenshotGrid({ + active, + selected, + onToggle, +}: { + active: boolean; + selected: MediaLibrary.Asset[]; + onToggle: (asset: MediaLibrary.Asset) => void; +}) { + const theme = useTheme(); + const { width } = useWindowDimensions(); + const [permission, setPermission] = + useState(null); + + useEffect(() => { + if (!active) return; + void MediaLibrary.getPermissionsAsync().then(setPermission); + }, [active]); + + const granted = permission?.granted ?? false; + + const { data: assets, isLoading } = useQuery({ + queryKey: ["media-library", "screenshots"], + enabled: active && granted, + staleTime: 30_000, + queryFn: async () => { + const albums = await MediaLibrary.getAlbumsAsync({ + includeSmartAlbums: true, + }); + const screenshotsAlbum = albums.find( + (album) => album.type === "smartAlbum" && album.title === "Screenshots", + ); + if (!screenshotsAlbum) return []; + const page = await MediaLibrary.getAssetsAsync({ + album: screenshotsAlbum, + first: SCREENSHOTS_LIMIT, + mediaType: "photo", + sortBy: [["creationTime", false]], + }); + return page.assets; + }, + }); + + if (!granted) { + return ( + + Allow photo access to see screenshots + + ); + } + + const tileSize = + (width - GRID_HORIZONTAL_PADDING * 2 - GRID_GAP * (GRID_COLUMNS - 1)) / + GRID_COLUMNS; + + return ( + + {(assets ?? []).map((asset) => { + const selectionIndex = selected.findIndex( + (entry) => entry.id === asset.id, + ); + const isSelected = selectionIndex >= 0; + return ( + onToggle(asset)} + > + + {isSelected ? ( + + + + {selectionIndex + 1} + + + + ) : null} + + ); + })} + {isLoading ? ( + + + + ) : null} + {!isLoading && (assets ?? []).length === 0 ? ( + + No screenshots found + + ) : null} + + ); +} diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/ContextSheet/components/ScreenshotGrid/index.ts b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/ContextSheet/components/ScreenshotGrid/index.ts new file mode 100644 index 00000000000..8eff60692bc --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/ContextSheet/components/ScreenshotGrid/index.ts @@ -0,0 +1 @@ +export { ScreenshotGrid } from "./ScreenshotGrid"; diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/ContextSheet/index.ts b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/ContextSheet/index.ts new file mode 100644 index 00000000000..4193521ab87 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/ContextSheet/index.ts @@ -0,0 +1 @@ +export { ContextSheet } from "./ContextSheet"; diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/hooks/useCreateChatWorkspace/index.ts b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/hooks/useCreateChatWorkspace/index.ts new file mode 100644 index 00000000000..5752314de8b --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/hooks/useCreateChatWorkspace/index.ts @@ -0,0 +1 @@ +export { useCreateChatWorkspace } from "./useCreateChatWorkspace"; diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/hooks/useCreateChatWorkspace/useCreateChatWorkspace.ts b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/hooks/useCreateChatWorkspace/useCreateChatWorkspace.ts new file mode 100644 index 00000000000..ace2c2e5074 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/hooks/useCreateChatWorkspace/useCreateChatWorkspace.ts @@ -0,0 +1,87 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { randomUUID } from "expo-crypto"; +import { File } from "expo-file-system"; +import { useRouter } from "expo-router"; +import { Alert } from "react-native"; +import type { PromptInputMessage } from "@/components/ai-elements/prompt-input"; +import { getHostServiceClientByUrl } from "@/lib/host-service/client"; +import type { NewChatTarget } from "../useNewChatTargets"; + +const FALLBACK_MEDIA_TYPE = "application/octet-stream"; + +interface CreateChatWorkspaceArgs { + target: NewChatTarget; + baseBranch: string | null; + modelId: string; + message: PromptInputMessage; +} + +/** + * Creates a workspace on the target host with the "superset" agent sugar — + * the host creates the cloud chat session and fires the first message + * itself. Attachments upload to the same host first (they're host-local). + */ +export function useCreateChatWorkspace() { + const router = useRouter(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + target, + baseBranch, + modelId, + message, + }: CreateChatWorkspaceArgs) => { + const client = getHostServiceClientByUrl(target.hostUrl); + + const attachmentIds = await Promise.all( + message.attachments.map(async (attachment) => { + const base64 = await new File(attachment.uri).base64(); + const uploaded = await client.attachments.upload.mutate({ + data: { kind: "base64", data: base64 }, + mediaType: attachment.mediaType ?? FALLBACK_MEDIA_TYPE, + originalFilename: attachment.name, + }); + return uploaded.attachmentId; + }), + ); + + return client.workspaces.create.mutate({ + id: randomUUID(), + projectId: target.projectId, + baseBranch: baseBranch ?? undefined, + agents: [ + { + agent: "superset", + prompt: message.text.trim(), + attachmentIds: attachmentIds.length > 0 ? attachmentIds : undefined, + model: modelId, + }, + ], + }); + }, + onSuccess: (result) => { + void queryClient.invalidateQueries({ + queryKey: ["host-service", "workspaces", "list"], + }); + const workspaceId = result.workspace.id; + const agentResult = result.agents[0]; + if (agentResult?.ok && agentResult.kind === "chat") { + router.push( + `/(authenticated)/workspace/${workspaceId}/chat/${agentResult.sessionId}`, + ); + return; + } + router.push(`/(authenticated)/workspace/${workspaceId}/chat`); + if (agentResult && !agentResult.ok) { + Alert.alert("Chat failed to start", agentResult.error); + } + }, + onError: (error) => { + Alert.alert( + "Could not create workspace", + error instanceof Error ? error.message : String(error), + ); + }, + }); +} diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/hooks/useNewChatTargets/index.ts b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/hooks/useNewChatTargets/index.ts new file mode 100644 index 00000000000..5a7f71ac512 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/hooks/useNewChatTargets/index.ts @@ -0,0 +1,5 @@ +export { + type NewChatTarget, + targetKeyFor, + useNewChatTargets, +} from "./useNewChatTargets"; diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/hooks/useNewChatTargets/useNewChatTargets.ts b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/hooks/useNewChatTargets/useNewChatTargets.ts new file mode 100644 index 00000000000..be277c1a828 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/hooks/useNewChatTargets/useNewChatTargets.ts @@ -0,0 +1,131 @@ +import { useLiveQuery } from "@tanstack/react-db"; +import { useQueries } from "@tanstack/react-query"; +import { compareDesc } from "date-fns"; +import { useMemo } from "react"; +import type { HostWorkspaceItem } from "@/hooks/useHostWorkspaces"; +import { + buildRelayHostUrl, + getHostServiceClientByUrl, +} from "@/lib/host-service/client"; +import { useCollections } from "@/screens/(authenticated)/providers/CollectionsProvider"; +import { useWorkspacesFilterStore } from "../../../../stores/workspacesFilterStore"; +import { useNewChatPreferencesStore } from "../../stores/newChatPreferencesStore"; + +export interface NewChatTarget { + key: string; + projectId: string; + projectName: string; + projectIconUrl: string | null; + machineId: string; + hostName: string; + hostUrl: string; +} + +export function targetKeyFor(projectId: string, machineId: string) { + return `${projectId}:${machineId}`; +} + +/** + * All (project, online host) pairs a new chat workspace can be created on, + * from fanning out `project.list` to every online host, plus the default + * pick: last used target, else the filtered project, else the most recently + * updated workspace's target. + */ +export function useNewChatTargets(workspaces: HostWorkspaceItem[] = []): { + targets: NewChatTarget[]; + defaultTarget: NewChatTarget | null; +} { + const collections = useCollections(); + const persistedTargetKey = useNewChatPreferencesStore( + (state) => state.targetKey, + ); + const projectFilter = useWorkspacesFilterStore( + (state) => state.projectFilter, + ); + + const { data: hosts } = useLiveQuery( + (q) => q.from({ v2Hosts: collections.v2Hosts }), + [collections], + ); + const { data: projects } = useLiveQuery( + (q) => q.from({ v2Projects: collections.v2Projects }), + [collections], + ); + + const onlineHosts = useMemo( + () => + (hosts ?? []) + .filter((host) => host.isOnline) + .map((host) => ({ + machineId: host.machineId, + name: host.name, + hostUrl: buildRelayHostUrl(host.organizationId, host.machineId), + })), + [hosts], + ); + + const projectListQueries = useQueries({ + queries: onlineHosts.map((host) => ({ + queryKey: ["host-service", "projects", "list", host.machineId], + queryFn: () => + getHostServiceClientByUrl(host.hostUrl).project.list.query(), + staleTime: 60_000, + retry: 1, + networkMode: "always" as const, + })), + }); + + const targets = useMemo(() => { + const projectsById = new Map( + (projects ?? []).map((project) => [project.id, project]), + ); + const result: NewChatTarget[] = []; + onlineHosts.forEach((host, index) => { + for (const row of projectListQueries[index]?.data ?? []) { + const project = projectsById.get(row.id); + if (!project) continue; + result.push({ + key: targetKeyFor(project.id, host.machineId), + projectId: project.id, + projectName: project.name, + projectIconUrl: project.iconUrl, + machineId: host.machineId, + hostName: host.name, + hostUrl: host.hostUrl, + }); + } + }); + return result.sort((a, b) => a.projectName.localeCompare(b.projectName)); + }, [onlineHosts, projectListQueries, projects]); + + const defaultTarget = useMemo(() => { + if (targets.length === 0) return null; + + const persisted = targets.find( + (target) => target.key === persistedTargetKey, + ); + if (persisted) return persisted; + + const sortedWorkspaces = [...workspaces].sort((a, b) => + compareDesc(a.updatedAt, b.updatedAt), + ); + const candidateProjectIds = projectFilter + ? [projectFilter] + : sortedWorkspaces.map((workspace) => workspace.projectId); + for (const projectId of candidateProjectIds) { + const recentWorkspace = sortedWorkspaces.find( + (workspace) => workspace.projectId === projectId, + ); + const match = + targets.find( + (target) => + target.projectId === projectId && + target.machineId === recentWorkspace?.hostId, + ) ?? targets.find((target) => target.projectId === projectId); + if (match) return match; + } + return targets[0] ?? null; + }, [targets, persistedTargetKey, projectFilter, workspaces]); + + return { targets, defaultTarget }; +} diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/index.ts b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/index.ts new file mode 100644 index 00000000000..58cb1f3343e --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/index.ts @@ -0,0 +1 @@ +export { NewChatWidget } from "./NewChatWidget"; diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/stores/newChatPreferencesStore/index.ts b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/stores/newChatPreferencesStore/index.ts new file mode 100644 index 00000000000..6f1578396a0 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/stores/newChatPreferencesStore/index.ts @@ -0,0 +1 @@ +export { useNewChatPreferencesStore } from "./newChatPreferencesStore"; diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/stores/newChatPreferencesStore/newChatPreferencesStore.ts b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/stores/newChatPreferencesStore/newChatPreferencesStore.ts new file mode 100644 index 00000000000..31ccc9d5848 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/stores/newChatPreferencesStore/newChatPreferencesStore.ts @@ -0,0 +1,39 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { SUPERSET_CHAT_MODELS } from "@superset/shared/agent-models"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +const DEFAULT_MODEL_ID = + SUPERSET_CHAT_MODELS[0]?.id ?? "anthropic/claude-opus-4-8"; + +interface NewChatPreferencesStore { + modelId: string; + /** "projectId:machineId" of the last used target. */ + targetKey: string | null; + /** Draft base branch for the next chat; null = default branch. */ + baseBranch: string | null; + setModelId: (modelId: string) => void; + setTargetKey: (targetKey: string) => void; + setBaseBranch: (baseBranch: string | null) => void; +} + +export const useNewChatPreferencesStore = create()( + persist( + (set) => ({ + modelId: DEFAULT_MODEL_ID, + targetKey: null, + baseBranch: null, + setModelId: (modelId) => set({ modelId }), + setTargetKey: (targetKey) => set({ targetKey, baseBranch: null }), + setBaseBranch: (baseBranch) => set({ baseBranch }), + }), + { + name: "new-chat-preferences", + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => ({ + modelId: state.modelId, + targetKey: state.targetKey, + }), + }, + ), +); diff --git a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/OrganizationHeaderButton/OrganizationHeaderButton.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationHeaderButton/OrganizationHeaderButton.tsx similarity index 88% rename from apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/OrganizationHeaderButton/OrganizationHeaderButton.tsx rename to apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationHeaderButton/OrganizationHeaderButton.tsx index b8a34a2e6e7..e3920f64eb6 100644 --- a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/OrganizationHeaderButton/OrganizationHeaderButton.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationHeaderButton/OrganizationHeaderButton.tsx @@ -2,7 +2,7 @@ import { Stack } from "expo-router"; import { ChevronsUpDown } from "lucide-react-native"; import { Pressable } from "react-native"; import { Text } from "@/components/ui/text"; -import { OrganizationAvatar } from "../OrganizationSwitcherSheet/components/OrganizationAvatar"; +import { OrganizationAvatar } from "@/screens/(authenticated)/components/OrganizationAvatar"; export function OrganizationHeaderButton({ name, diff --git a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/OrganizationHeaderButton/index.ts b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationHeaderButton/index.ts similarity index 100% rename from apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/OrganizationHeaderButton/index.ts rename to apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationHeaderButton/index.ts diff --git a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/OrganizationSwitcherSheet/OrganizationSwitcherSheet.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationSwitcherSheet/OrganizationSwitcherSheet.tsx similarity index 62% rename from apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/OrganizationSwitcherSheet/OrganizationSwitcherSheet.tsx rename to apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationSwitcherSheet/OrganizationSwitcherSheet.tsx index 7bc7ee92c0f..4f5c7fa98ec 100644 --- a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/OrganizationSwitcherSheet/OrganizationSwitcherSheet.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationSwitcherSheet/OrganizationSwitcherSheet.tsx @@ -2,13 +2,17 @@ import { BottomSheet, Group, Host, RNHostView } from "@expo/ui/swift-ui"; import { background, environment, + presentationBackground, presentationDragIndicator, } from "@expo/ui/swift-ui/modifiers"; import Ionicons from "@expo/vector-icons/Ionicons"; +import { useRouter } from "expo-router"; import { Pressable, View } from "react-native"; import { Text } from "@/components/ui/text"; +import { useSignOut } from "@/hooks/useSignOut"; import { useTheme } from "@/hooks/useTheme"; -import { OrganizationAvatar } from "./components/OrganizationAvatar"; +import { OrganizationAvatar } from "@/screens/(authenticated)/components/OrganizationAvatar"; +import { hslToHex } from "../../utils/hslToHex"; export interface Organization { id: string; @@ -33,6 +37,18 @@ export function OrganizationSwitcherSheet({ width: number; }) { const theme = useTheme(); + const router = useRouter(); + const { signOut, isSigningOut } = useSignOut(); + + const handleSignOut = () => { + onIsPresentedChange(false); + void signOut(); + }; + + const handleOpenSettings = () => { + onIsPresentedChange(false); + router.push("/(authenticated)/settings"); + }; return ( @@ -46,6 +62,7 @@ export function OrganizationSwitcherSheet({ environment("colorScheme", "dark"), presentationDragIndicator("visible"), background(theme.background), + presentationBackground(hslToHex(theme.background)), ]} > @@ -83,7 +100,7 @@ export function OrganizationSwitcherSheet({ color: theme.mutedForeground, }} > - superset.sh/{organization.slug} + {organization.slug} ) : null} @@ -97,6 +114,43 @@ export function OrganizationSwitcherSheet({ ); })} + + + + + Settings + + + + + + Log out + + diff --git a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/OrganizationSwitcherSheet/index.ts b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationSwitcherSheet/index.ts similarity index 100% rename from apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/OrganizationSwitcherSheet/index.ts rename to apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationSwitcherSheet/index.ts diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/WorkspaceRow/WorkspaceRow.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/WorkspaceRow/WorkspaceRow.tsx new file mode 100644 index 00000000000..aefa4398297 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/WorkspaceRow/WorkspaceRow.tsx @@ -0,0 +1,164 @@ +import type { SelectGithubPullRequest } from "@superset/db/schema"; +import { formatDistanceToNow } from "date-fns"; +import { + CircleDot, + Cloud, + CloudOff, + GitMerge, + GitPullRequest, +} from "lucide-react-native"; +import { Linking, Pressable, StyleSheet, View } from "react-native"; +import Animated, { + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; +import { Icon } from "@/components/ui/icon"; +import { Text } from "@/components/ui/text"; +import type { + HostWorkspaceItem, + HostWorkspacesCacheOps, +} from "@/hooks/useHostWorkspaces"; +import { useTheme } from "@/hooks/useTheme"; +import type { DiffStats } from "../../hooks/useVisibleDiffStats"; +import { WorkspaceRowMenu } from "./components/WorkspaceRowMenu"; + +const PR_BADGE_CONFIG = { + closed: { + containerClassName: "bg-destructive/10", + icon: CircleDot, + iconClassName: "text-destructive", + }, + draft: { + containerClassName: "bg-muted", + icon: GitPullRequest, + iconClassName: "text-muted-foreground", + }, + merged: { + containerClassName: "bg-purple-500/10", + icon: GitMerge, + iconClassName: "text-purple-500", + }, + open: { + containerClassName: "bg-emerald-500/10", + icon: GitPullRequest, + iconClassName: "text-emerald-500", + }, +} as const; + +export type PrBadgeState = keyof typeof PR_BADGE_CONFIG; + +export function prStateFor(pullRequest: SelectGithubPullRequest): PrBadgeState { + if (pullRequest.mergedAt != null) return "merged"; + if (pullRequest.isDraft) return "draft"; + if (pullRequest.state === "closed") return "closed"; + return "open"; +} + +const ADDITIONS_COLOR = "#3fb950"; +const DELETIONS_COLOR = "#f85149"; + +export function WorkspaceRow({ + workspace, + pullRequest, + diffStats, + cache, +}: { + workspace: HostWorkspaceItem; + pullRequest?: SelectGithubPullRequest; + diffStats: DiffStats | null; + cache: HostWorkspacesCacheOps; +}) { + const prBadge = pullRequest ? PR_BADGE_CONFIG[prStateFor(pullRequest)] : null; + + const theme = useTheme(); + const pressProgress = useSharedValue(0); + const highlightStyle = useAnimatedStyle(() => ({ + opacity: pressProgress.value, + })); + + return ( + + { + pressProgress.value = withTiming(1, { duration: 300 }); + }} + onPressOut={() => { + pressProgress.value = withTiming(0, { duration: 150 }); + }} + > + + + + + + + {workspace.name} + + + + {workspace.branch} + + + ·{" "} + {workspace.worktreeExists === false + ? "worktree missing" + : formatDistanceToNow(workspace.updatedAt, { + addSuffix: true, + })} + + + + + {diffStats && (diffStats.additions > 0 || diffStats.deletions > 0) ? ( + + + +{diffStats.additions} + {" "} + + −{diffStats.deletions} + + + ) : null} + {pullRequest && prBadge ? ( + Linking.openURL(pullRequest.url)} + className={`flex-row items-center gap-1 rounded-md px-2 py-1 ${prBadge.containerClassName}`} + > + + + #{pullRequest.prNumber} + + + ) : null} + + + + ); +} diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/WorkspaceRow/components/WorkspaceRowMenu/WorkspaceRowMenu.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/WorkspaceRow/components/WorkspaceRowMenu/WorkspaceRowMenu.tsx new file mode 100644 index 00000000000..090b3a49fd0 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/WorkspaceRow/components/WorkspaceRowMenu/WorkspaceRowMenu.tsx @@ -0,0 +1,140 @@ +import { prompt } from "@superset/alert-prompt"; +import * as Clipboard from "expo-clipboard"; +import { Link } from "expo-router"; +import type { ReactNode } from "react"; +import { Alert, Share } from "react-native"; +import type { + HostWorkspaceItem, + HostWorkspacesCacheOps, +} from "@/hooks/useHostWorkspaces"; +import { getHostServiceClientByUrl } from "@/lib/host-service/client"; +import { isTrpcErrorWithData } from "@/lib/host-service/errors"; + +export function WorkspaceRowMenu({ + workspace, + cache, + children, +}: { + workspace: HostWorkspaceItem; + cache: HostWorkspacesCacheOps; + children: ReactNode; +}) { + const renameWorkspace = async () => { + const hostUrl = cache.resolveHostUrl(workspace.hostId); + if (!hostUrl) { + Alert.alert("Host is not online"); + return; + } + const name = await prompt({ + title: "Rename workspace", + defaultValue: workspace.name, + confirmText: "Rename", + selectText: true, + }); + const trimmed = name?.trim(); + if (!trimmed || trimmed === workspace.name) return; + try { + await getHostServiceClientByUrl(hostUrl).workspace.update.mutate({ + id: workspace.id, + name: trimmed, + }); + } catch { + Alert.alert("Rename failed"); + } + cache.invalidateHost(workspace.hostId); + }; + + const destroyWorkspace = async (force: boolean) => { + const hostUrl = cache.resolveHostUrl(workspace.hostId); + if (!hostUrl) { + Alert.alert("Host is not online"); + return; + } + try { + await getHostServiceClientByUrl(hostUrl).workspaceCleanup.destroy.mutate({ + workspaceId: workspace.id, + deleteBranch: false, + force, + }); + cache.removeWorkspace(workspace.hostId, workspace.id); + } catch (error) { + if (isTrpcErrorWithData(error)) { + if (error.data.deleteInProgress) { + Alert.alert("Delete already in progress"); + return; + } + if (error.data.code === "CONFLICT" || error.data.teardownFailure) { + Alert.alert( + error.data.teardownFailure + ? "Teardown script failed" + : "Worktree has uncommitted changes", + undefined, + [ + { style: "cancel", text: "Cancel" }, + { + onPress: () => void destroyWorkspace(true), + style: "destructive", + text: "Force delete", + }, + ], + ); + return; + } + } + Alert.alert("Delete failed"); + } + }; + + const deleteWorkspace = () => { + if (!cache.resolveHostUrl(workspace.hostId)) { + Alert.alert("Host is not online"); + return; + } + Alert.alert( + "Delete workspace", + `Delete "${workspace.name}"? This removes its worktree from the host.`, + [ + { style: "cancel", text: "Cancel" }, + { + onPress: () => void destroyWorkspace(false), + style: "destructive", + text: "Delete", + }, + ], + ); + }; + + return ( + + {children} + + void renameWorkspace()}> + Rename + + {workspace.type !== "main" ? ( + + Delete + + ) : null} + + void Clipboard.setStringAsync(workspace.id)} + > + Copy ID + + + void Share.share({ + url: `https://app.superset.sh/workspaces/${workspace.id}`, + }) + } + > + Share + + + + + ); +} diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/WorkspaceRow/components/WorkspaceRowMenu/index.ts b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/WorkspaceRow/components/WorkspaceRowMenu/index.ts new file mode 100644 index 00000000000..fadc4098642 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/WorkspaceRow/components/WorkspaceRowMenu/index.ts @@ -0,0 +1 @@ +export { WorkspaceRowMenu } from "./WorkspaceRowMenu"; diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/WorkspaceRow/index.ts b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/WorkspaceRow/index.ts new file mode 100644 index 00000000000..d687f73251d --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/WorkspaceRow/index.ts @@ -0,0 +1 @@ +export { prStateFor, WorkspaceRow } from "./WorkspaceRow"; diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/hooks/useVisibleDiffStats/index.ts b/apps/mobile/screens/(authenticated)/(home)/workspaces/hooks/useVisibleDiffStats/index.ts new file mode 100644 index 00000000000..029dbf0f7cd --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/hooks/useVisibleDiffStats/index.ts @@ -0,0 +1 @@ +export { type DiffStats, useVisibleDiffStats } from "./useVisibleDiffStats"; diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/hooks/useVisibleDiffStats/useVisibleDiffStats.ts b/apps/mobile/screens/(authenticated)/(home)/workspaces/hooks/useVisibleDiffStats/useVisibleDiffStats.ts new file mode 100644 index 00000000000..567af88a8e0 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/hooks/useVisibleDiffStats/useVisibleDiffStats.ts @@ -0,0 +1,99 @@ +import { useQueries, useQueryClient } from "@tanstack/react-query"; +import { useMemo } from "react"; +import type { HostWorkspaceItem } from "@/hooks/useHostWorkspaces"; +import { + type GitStatusSnapshot, + getHostServiceClientByUrl, +} from "@/lib/host-service/client"; + +export interface DiffStats { + additions: number; + deletions: number; +} + +const DIFF_STATS_STALE_MS = 60_000; +const DIFF_STATS_GC_MS = 30 * 60_000; + +function aggregateDiffStats(status: GitStatusSnapshot): DiffStats { + const byPath = new Map(); + for (const file of [ + ...status.againstBase, + ...status.staged, + ...status.unstaged, + ]) { + byPath.set(file.path, file); + } + let additions = 0; + let deletions = 0; + for (const file of byPath.values()) { + additions += file.additions; + deletions += file.deletions; + } + return { additions, deletions }; +} + +/** + * Live working-tree diff stats. Fetching is viewport-bounded (only settled + * visible rows subscribe — one screenful, not the whole project), but the + * returned map is built from the whole query cache so rows scrolling back + * into view render their last-known stats instead of flickering in. + */ +export function useVisibleDiffStats({ + visibleIds, + workspacesById, + resolveHostUrl, +}: { + visibleIds: string[]; + workspacesById: Map; + resolveHostUrl: (hostId: string) => string | null; +}): Map { + const queryClient = useQueryClient(); + + const eligible = useMemo(() => { + const result: Array<{ workspaceId: string; hostUrl: string }> = []; + for (const workspaceId of visibleIds) { + const workspace = workspacesById.get(workspaceId); + if ( + !workspace || + !workspace.hostReachable || + workspace.worktreeExists === false + ) { + continue; + } + const hostUrl = resolveHostUrl(workspace.hostId); + if (hostUrl) result.push({ workspaceId, hostUrl }); + } + return result; + }, [visibleIds, workspacesById, resolveHostUrl]); + + const _queries = useQueries({ + queries: eligible.map(({ workspaceId, hostUrl }) => ({ + queryKey: ["diff-stats", hostUrl, workspaceId] as const, + staleTime: DIFF_STATS_STALE_MS, + gcTime: DIFF_STATS_GC_MS, + retry: 1, + networkMode: "always" as const, + queryFn: () => + getHostServiceClientByUrl(hostUrl).git.getStatus.query({ + workspaceId, + priority: "background", + }), + })), + }); + + return useMemo(() => { + const map = new Map(); + for (const [ + queryKey, + status, + ] of queryClient.getQueriesData({ + queryKey: ["diff-stats"], + })) { + const workspaceId = queryKey[2]; + if (typeof workspaceId === "string" && status) { + map.set(workspaceId, aggregateDiffStats(status)); + } + } + return map; + }, [queryClient]); +} diff --git a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/index.ts b/apps/mobile/screens/(authenticated)/(home)/workspaces/index.ts similarity index 100% rename from apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/index.ts rename to apps/mobile/screens/(authenticated)/(home)/workspaces/index.ts diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/stores/workspacesFilterStore/index.ts b/apps/mobile/screens/(authenticated)/(home)/workspaces/stores/workspacesFilterStore/index.ts new file mode 100644 index 00000000000..389b6ffef6e --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/stores/workspacesFilterStore/index.ts @@ -0,0 +1,5 @@ +export { + SORT_OPTIONS, + useWorkspacesFilterStore, + type WorkspaceSort, +} from "./workspacesFilterStore"; diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/stores/workspacesFilterStore/workspacesFilterStore.ts b/apps/mobile/screens/(authenticated)/(home)/workspaces/stores/workspacesFilterStore/workspacesFilterStore.ts new file mode 100644 index 00000000000..4d35206d085 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/stores/workspacesFilterStore/workspacesFilterStore.ts @@ -0,0 +1,36 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +export type WorkspaceSort = "updatedAt" | "createdAt"; + +export const SORT_OPTIONS: { value: WorkspaceSort; label: string }[] = [ + { label: "Last updated", value: "updatedAt" }, + { label: "Date created", value: "createdAt" }, +]; + +interface WorkspacesFilterStore { + projectFilter: string | null; + hostFilter: string | null; + sort: WorkspaceSort; + setProjectFilter: (projectId: string | null) => void; + setHostFilter: (machineId: string | null) => void; + setSort: (sort: WorkspaceSort) => void; +} + +export const useWorkspacesFilterStore = create()( + persist( + (set) => ({ + projectFilter: null, + hostFilter: null, + sort: "updatedAt", + setProjectFilter: (projectId) => set({ projectFilter: projectId }), + setHostFilter: (machineId) => set({ hostFilter: machineId }), + setSort: (sort) => set({ sort }), + }), + { + name: "workspaces-filter", + storage: createJSONStorage(() => AsyncStorage), + }, + ), +); diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/utils/hslToHex/hslToHex.ts b/apps/mobile/screens/(authenticated)/(home)/workspaces/utils/hslToHex/hslToHex.ts new file mode 100644 index 00000000000..e549a73968b --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/utils/hslToHex/hslToHex.ts @@ -0,0 +1,23 @@ +/** + * Converts a THEME "hsl(h s% l%)" string to the hex form required by + * SwiftUI presentation modifiers like `presentationBackground`. + */ +export function hslToHex(hsl: string): string { + const match = hsl.match( + /hsl\(\s*([\d.]+)[,\s]+([\d.]+)%[,\s]+([\d.]+)%\s*\)/, + ); + if (!match) return "#000000"; + const hue = Number(match[1]); + const saturation = Number(match[2]) / 100; + const lightness = Number(match[3]) / 100; + const chroma = saturation * Math.min(lightness, 1 - lightness); + const channel = (n: number) => { + const k = (n + hue / 30) % 12; + const value = + lightness - chroma * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1))); + return Math.round(255 * value) + .toString(16) + .padStart(2, "0"); + }; + return `#${channel(0)}${channel(8)}${channel(4)}`; +} diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/utils/hslToHex/index.ts b/apps/mobile/screens/(authenticated)/(home)/workspaces/utils/hslToHex/index.ts new file mode 100644 index 00000000000..e41785e7170 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/utils/hslToHex/index.ts @@ -0,0 +1 @@ +export { hslToHex } from "./hslToHex"; diff --git a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/WorkspacesScreen.tsx b/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/WorkspacesScreen.tsx deleted file mode 100644 index b07991c9e24..00000000000 --- a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/WorkspacesScreen.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import { LegendList } from "@legendapp/list/react-native"; -import type { - SelectGithubPullRequest, - SelectV2Workspace, -} from "@superset/db/schema"; -import { useLiveQuery } from "@tanstack/react-db"; -import { compareDesc } from "date-fns"; -import { Stack, useRouter } from "expo-router"; -import { useCallback, useMemo, useState } from "react"; -import { Pressable, useWindowDimensions, View } from "react-native"; -import { Text } from "@/components/ui/text"; -import { useOrganizations } from "@/screens/(authenticated)/hooks/useOrganizations"; -import { useCollections } from "@/screens/(authenticated)/providers/CollectionsProvider"; -import { OrganizationHeaderButton } from "./components/OrganizationHeaderButton"; -import { OrganizationSwitcherSheet } from "./components/OrganizationSwitcherSheet"; -import { ProjectAvatar } from "./components/ProjectAvatar"; -import { WorkspaceActionsSheet } from "./components/WorkspaceActionsSheet"; -import { - type FilterableProject, - WorkspaceFilterSheet, - type WorkspaceSort, -} from "./components/WorkspaceFilterSheet"; -import { WorkspaceRow } from "./components/WorkspaceRow"; - -export function WorkspacesScreen() { - const router = useRouter(); - const [sheetOpen, setSheetOpen] = useState(false); - const [filterSheetOpen, setFilterSheetOpen] = useState(false); - const [projectFilter, setProjectFilter] = useState(null); - const [sort, setSort] = useState("updatedAt"); - const [actionsWorkspace, setActionsWorkspace] = - useState(null); - const { width } = useWindowDimensions(); - const collections = useCollections(); - const { - organizations, - activeOrganization, - activeOrganizationId, - switchOrganization, - } = useOrganizations(); - - const { data: workspaces, isReady: workspacesReady } = useLiveQuery( - (q) => q.from({ v2Workspaces: collections.v2Workspaces }), - [collections], - ); - const { data: projects } = useLiveQuery( - (q) => q.from({ v2Projects: collections.v2Projects }), - [collections], - ); - const { data: pullRequests } = useLiveQuery( - (q) => q.from({ githubPullRequests: collections.githubPullRequests }), - [collections], - ); - const { data: hosts } = useLiveQuery( - (q) => q.from({ v2Hosts: collections.v2Hosts }), - [collections], - ); - - const sortedProjects = useMemo( - () => [...(projects ?? [])].sort((a, b) => a.name.localeCompare(b.name)), - [projects], - ); - - const filterableProjects = useMemo(() => { - const counts = new Map(); - for (const workspace of workspaces ?? []) { - counts.set( - workspace.projectId, - (counts.get(workspace.projectId) ?? 0) + 1, - ); - } - return sortedProjects.map((project) => ({ - id: project.id, - name: project.name, - iconUrl: project.iconUrl, - workspaceCount: counts.get(project.id) ?? 0, - })); - }, [sortedProjects, workspaces]); - - const selectedProjectId = projectFilter ?? sortedProjects[0]?.id ?? null; - const selectedProject = sortedProjects.find( - (project) => project.id === selectedProjectId, - ); - - const visibleWorkspaces = useMemo(() => { - return (workspaces ?? []) - .filter((workspace) => workspace.projectId === selectedProjectId) - .sort((a, b) => compareDesc(a[sort], b[sort])); - }, [workspaces, selectedProjectId, sort]); - - const pullRequestsByBranch = useMemo(() => { - const byBranch = new Map(); - const rank = (state: string) => - state === "open" ? 2 : state === "merged" ? 1 : 0; - for (const pullRequest of pullRequests ?? []) { - const existing = byBranch.get(pullRequest.headBranch); - if (!existing || rank(pullRequest.state) > rank(existing.state)) { - byBranch.set(pullRequest.headBranch, pullRequest); - } - } - return byBranch; - }, [pullRequests]); - - const hostsById = useMemo( - () => new Map((hosts ?? []).map((host) => [host.machineId, host])), - [hosts], - ); - - const renderItem = useCallback( - ({ item }: { item: SelectV2Workspace }) => ( - - router.push(`/(authenticated)/workspace/${item.id}/chat`) - } - onLongPress={() => setActionsWorkspace(item)} - /> - ), - [pullRequestsByBranch, hostsById, router], - ); - - const handleSwitchOrganization = (organizationId: string) => { - setSheetOpen(false); - switchOrganization(organizationId); - }; - - return ( - <> - setSheetOpen(true)} - /> - - {}} /> - - setFilterSheetOpen(true)} - style={{ height: 24, width: 24 }} - > - - - - - item.id} - renderItem={renderItem} - ListEmptyComponent={ - workspacesReady ? ( - - - No workspaces in this project yet - - - ) : null - } - /> - - { - setProjectFilter(projectId); - setFilterSheetOpen(false); - }} - sort={sort} - onChangeSort={setSort} - width={width} - /> - { - if (!value) setActionsWorkspace(null); - }} - width={width} - /> - - ); -} diff --git a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceActionsSheet/WorkspaceActionsSheet.tsx b/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceActionsSheet/WorkspaceActionsSheet.tsx deleted file mode 100644 index 85d894061dc..00000000000 --- a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceActionsSheet/WorkspaceActionsSheet.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import { BottomSheet, Group, Host, RNHostView } from "@expo/ui/swift-ui"; -import { - background, - environment, - presentationDragIndicator, -} from "@expo/ui/swift-ui/modifiers"; -import type { SelectV2Workspace } from "@superset/db/schema"; -import { Pencil, Trash2 } from "lucide-react-native"; -import { useEffect, useState } from "react"; -import { Alert, Pressable, View } from "react-native"; -import { Text } from "@/components/ui/text"; -import { useTheme } from "@/hooks/useTheme"; -import { apiClient } from "@/lib/trpc/client"; - -export function WorkspaceActionsSheet({ - workspace, - isPresented, - onIsPresentedChange, - width, -}: { - workspace: SelectV2Workspace | null; - isPresented: boolean; - onIsPresentedChange: (value: boolean) => void; - width: number; -}) { - const theme = useTheme(); - const [retained, setRetained] = useState(workspace); - - useEffect(() => { - if (workspace) setRetained(workspace); - }, [workspace]); - - const target = workspace ?? retained; - - const handleRename = () => { - const ws = target; - onIsPresentedChange(false); - if (!ws) return; - setTimeout(() => { - Alert.prompt( - "Rename workspace", - undefined, - [ - { style: "cancel", text: "Cancel" }, - { - onPress: async (name?: string) => { - const trimmed = name?.trim(); - if (!trimmed || trimmed === ws.name) return; - try { - await apiClient.v2Workspace.update.mutate({ - id: ws.id, - name: trimmed, - }); - } catch { - Alert.alert("Rename failed"); - } - }, - text: "Rename", - }, - ], - "plain-text", - ws.name, - ); - }, 350); - }; - - const handleDelete = () => { - const ws = target; - onIsPresentedChange(false); - if (!ws) return; - setTimeout(() => { - Alert.alert("Delete workspace", `Delete "${ws.name}"?`, [ - { style: "cancel", text: "Cancel" }, - { - onPress: async () => { - try { - await apiClient.v2Workspace.delete.mutate({ id: ws.id }); - } catch { - Alert.alert("Delete failed"); - } - }, - style: "destructive", - text: "Delete", - }, - ]); - }, 350); - }; - - return ( - - - - - - - {target?.name} - - - - - Rename - - - - - - Delete - - - - - - - - ); -} diff --git a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceActionsSheet/index.ts b/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceActionsSheet/index.ts deleted file mode 100644 index 740c2f11861..00000000000 --- a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceActionsSheet/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { WorkspaceActionsSheet } from "./WorkspaceActionsSheet"; diff --git a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceFilterSheet/WorkspaceFilterSheet.tsx b/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceFilterSheet/WorkspaceFilterSheet.tsx deleted file mode 100644 index 015659a9471..00000000000 --- a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceFilterSheet/WorkspaceFilterSheet.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { BottomSheet, Group, Host, RNHostView } from "@expo/ui/swift-ui"; -import { - background, - environment, - presentationDragIndicator, -} from "@expo/ui/swift-ui/modifiers"; -import Ionicons from "@expo/vector-icons/Ionicons"; -import { Pressable, ScrollView, View } from "react-native"; -import { Text } from "@/components/ui/text"; -import { useTheme } from "@/hooks/useTheme"; -import { ProjectAvatar } from "../ProjectAvatar"; - -export type WorkspaceSort = "updatedAt" | "createdAt"; - -export interface FilterableProject { - id: string; - name: string; - iconUrl?: string | null; - workspaceCount: number; -} - -const SORT_OPTIONS: { value: WorkspaceSort; label: string }[] = [ - { label: "Last updated", value: "updatedAt" }, - { label: "Date created", value: "createdAt" }, -]; - -export function WorkspaceFilterSheet({ - isPresented, - onIsPresentedChange, - projects, - selectedProjectId, - onSelectProject, - sort, - onChangeSort, - width, -}: { - isPresented: boolean; - onIsPresentedChange: (value: boolean) => void; - projects: FilterableProject[]; - selectedProjectId: string | null; - onSelectProject: (projectId: string) => void; - sort: WorkspaceSort; - onChangeSort: (sort: WorkspaceSort) => void; - width: number; -}) { - const theme = useTheme(); - - return ( - - - - - - - Sort by - - {SORT_OPTIONS.map((option) => { - const isActive = option.value === sort; - return ( - onChangeSort(option.value)} - className="flex-row items-center gap-2.5 py-2.5" - > - - {option.label} - - {isActive ? ( - - ) : null} - - ); - })} - - Project - - - {projects.map((project) => { - const isActive = project.id === selectedProjectId; - return ( - onSelectProject(project.id)} - className="flex-row items-center gap-2.5 py-2.5" - > - - - {project.name} - - - {project.workspaceCount} - - {isActive ? ( - - ) : null} - - ); - })} - - - - - - - ); -} diff --git a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceFilterSheet/index.ts b/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceFilterSheet/index.ts deleted file mode 100644 index 12c36a77b98..00000000000 --- a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceFilterSheet/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { - type FilterableProject, - WorkspaceFilterSheet, - type WorkspaceSort, -} from "./WorkspaceFilterSheet"; diff --git a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceRow/WorkspaceRow.tsx b/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceRow/WorkspaceRow.tsx deleted file mode 100644 index 3b683a08f2f..00000000000 --- a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceRow/WorkspaceRow.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import type { - SelectGithubPullRequest, - SelectV2Workspace, -} from "@superset/db/schema"; -import { formatDistanceToNow } from "date-fns"; -import { - Circle, - CircleDot, - Cloud, - CloudOff, - GitMerge, - GitPullRequest, -} from "lucide-react-native"; -import { Linking, Pressable, View } from "react-native"; -import { Icon } from "@/components/ui/icon"; -import { Text } from "@/components/ui/text"; - -const PR_BADGE_CONFIG = { - closed: { - containerClassName: "bg-destructive/10", - icon: CircleDot, - iconClassName: "text-destructive", - }, - draft: { - containerClassName: "bg-muted", - icon: GitPullRequest, - iconClassName: "text-muted-foreground", - }, - merged: { - containerClassName: "bg-purple-500/10", - icon: GitMerge, - iconClassName: "text-purple-500", - }, - open: { - containerClassName: "bg-emerald-500/10", - icon: GitPullRequest, - iconClassName: "text-emerald-500", - }, -} as const; - -type PrBadgeState = keyof typeof PR_BADGE_CONFIG; - -const ADDITIONS_COLOR = "#3fb950"; -const DELETIONS_COLOR = "#f85149"; - -export function WorkspaceRow({ - workspace, - pullRequest, - hostOnline, - onPress, - onLongPress, -}: { - workspace: SelectV2Workspace; - pullRequest?: SelectGithubPullRequest; - hostOnline?: boolean; - onPress: () => void; - onLongPress: () => void; -}) { - const prState: PrBadgeState | null = pullRequest - ? pullRequest.isDraft && pullRequest.state === "open" - ? "draft" - : pullRequest.state === "merged" - ? "merged" - : pullRequest.state === "closed" - ? "closed" - : "open" - : null; - const prBadge = prState ? PR_BADGE_CONFIG[prState] : null; - - const HostIcon = - hostOnline === undefined ? Circle : hostOnline ? Cloud : CloudOff; - - return ( - - - - - - - {workspace.name} - - - - {workspace.branch} - - - · {formatDistanceToNow(workspace.updatedAt, { addSuffix: true })} - - - - - {pullRequest ? ( - - - +{pullRequest.additions} - {" "} - - −{pullRequest.deletions} - - - ) : null} - {pullRequest && prBadge ? ( - Linking.openURL(pullRequest.url)} - className={`flex-row items-center gap-1 rounded-md px-2 py-1 ${prBadge.containerClassName}`} - > - - - #{pullRequest.prNumber} - - - ) : null} - - - ); -} diff --git a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceRow/index.ts b/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceRow/index.ts deleted file mode 100644 index 98c94bb73a2..00000000000 --- a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceRow/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { WorkspaceRow } from "./WorkspaceRow"; diff --git a/apps/mobile/screens/(authenticated)/(tabs)/(more)/MoreMenuScreen.tsx b/apps/mobile/screens/(authenticated)/(tabs)/(more)/MoreMenuScreen.tsx deleted file mode 100644 index 69d291beefc..00000000000 --- a/apps/mobile/screens/(authenticated)/(tabs)/(more)/MoreMenuScreen.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { useLiveQuery } from "@tanstack/react-db"; -import { useRouter } from "expo-router"; -import { - ArrowLeftRight, - ChevronRight, - LogOut, - Settings, -} from "lucide-react-native"; -import { useState } from "react"; -import { Pressable, ScrollView, View } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { Avatar, AvatarFallback } from "@/components/ui/avatar"; -import { Icon } from "@/components/ui/icon"; -import { Separator } from "@/components/ui/separator"; -import { Text } from "@/components/ui/text"; -import { useSignOut } from "@/hooks/useSignOut"; -import { authClient } from "@/lib/auth/client"; -import { useCollections } from "@/screens/(authenticated)/providers/CollectionsProvider"; - -export function MoreMenuScreen() { - const router = useRouter(); - const insets = useSafeAreaInsets(); - const { signOut } = useSignOut(); - const collections = useCollections(); - const [switching, setSwitching] = useState(false); - - const session = authClient.useSession(); - const activeOrgId = session.data?.session?.activeOrganizationId; - - const { data: orgs } = useLiveQuery( - (q) => q.from({ organizations: collections.organizations }), - [collections], - ); - - const activeOrg = orgs?.find((org) => org.id === activeOrgId); - const orgInitial = activeOrg?.name?.charAt(0).toUpperCase() ?? "?"; - const otherOrgs = orgs?.filter((org) => org.id !== activeOrgId) ?? []; - - const handleSwitchOrg = async (orgId: string) => { - if (orgId === activeOrgId) return; - setSwitching(true); - try { - await authClient.organization.setActive({ organizationId: orgId }); - router.replace("/(authenticated)/(home)"); - } catch (error) { - console.error("[org/switch] Failed to switch organization:", error); - } finally { - setSwitching(false); - } - }; - - return ( - - - {/* Org section */} - - - Organization - - - - - - {orgInitial} - - - - {activeOrg?.name ?? "Select Organization"} - - - {otherOrgs.length > 0 && ( - <> - - {otherOrgs.map((org) => ( - handleSwitchOrg(org.id)} - disabled={switching} - className="flex-row items-center gap-3 px-4 py-3" - > - - - Switch to {org.name} - - - ))} - - )} - - - - {/* Menu items */} - - - General - - - router.push("/(authenticated)/(more)/settings")} - className="flex-row items-center gap-3 px-4 py-3" - > - - Settings - - - - - - {/* Sign out */} - - - - - Log out - - - - - - ); -} diff --git a/apps/mobile/screens/(authenticated)/(tabs)/(more)/index.ts b/apps/mobile/screens/(authenticated)/(tabs)/(more)/index.ts deleted file mode 100644 index 6680f4454be..00000000000 --- a/apps/mobile/screens/(authenticated)/(tabs)/(more)/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { MoreMenuScreen } from "./MoreMenuScreen"; diff --git a/apps/mobile/screens/(authenticated)/(tabs)/(more)/settings/SettingsScreen.tsx b/apps/mobile/screens/(authenticated)/(tabs)/(more)/settings/SettingsScreen.tsx deleted file mode 100644 index 0f0d688a1dd..00000000000 --- a/apps/mobile/screens/(authenticated)/(tabs)/(more)/settings/SettingsScreen.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { useRouter } from "expo-router"; -import { ChevronLeft } from "lucide-react-native"; -import { Pressable, ScrollView, View } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Icon } from "@/components/ui/icon"; -import { Text } from "@/components/ui/text"; - -export function SettingsScreen() { - const router = useRouter(); - const insets = useSafeAreaInsets(); - - return ( - - - - router.back()} className="p-1"> - - - Settings - - - - - Account - - - - Account settings will appear here - - - - - - - Appearance - - - - Theme and display settings will appear here - - - - - - - Notifications - - - - Notification preferences will appear here - - - - - - ); -} diff --git a/apps/mobile/screens/(authenticated)/(tabs)/(search)/SearchScreen.tsx b/apps/mobile/screens/(authenticated)/(tabs)/(search)/SearchScreen.tsx deleted file mode 100644 index d37bca3baab..00000000000 --- a/apps/mobile/screens/(authenticated)/(tabs)/(search)/SearchScreen.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import type { SelectV2Workspace } from "@superset/db/schema"; -import { useLiveQuery } from "@tanstack/react-db"; -import { compareDesc } from "date-fns"; -import { Stack, useRouter } from "expo-router"; -import { useMemo, useState } from "react"; -import { FlatList, Pressable, View } from "react-native"; -import { Text } from "@/components/ui/text"; -import { THEME } from "@/lib/theme"; -import { useCollections } from "@/screens/(authenticated)/providers/CollectionsProvider"; - -export function SearchScreen() { - const router = useRouter(); - const collections = useCollections(); - const [query, setQuery] = useState(""); - - const { data: workspaces, isReady: workspacesReady } = useLiveQuery( - (q) => q.from({ v2Workspaces: collections.v2Workspaces }), - [collections], - ); - const { data: projects } = useLiveQuery( - (q) => q.from({ v2Projects: collections.v2Projects }), - [collections], - ); - - const results = useMemo(() => { - const needle = query.trim().toLowerCase(); - if (!needle) return []; - const projectNames = new Map( - (projects ?? []).map((project) => [project.id, project.name]), - ); - return (workspaces ?? []) - .filter( - (workspace) => - workspace.name.toLowerCase().includes(needle) || - workspace.branch.toLowerCase().includes(needle) || - (projectNames.get(workspace.projectId) ?? "") - .toLowerCase() - .includes(needle), - ) - .sort((a, b) => compareDesc(a.updatedAt, b.updatedAt)) - .slice(0, 50); - }, [workspaces, projects, query]); - - return ( - <> - setQuery(event.nativeEvent.text)} - onCancelButtonPress={() => setQuery("")} - /> - item.id} - contentContainerClassName="p-4 pb-28 gap-2" - keyboardDismissMode="on-drag" - ListEmptyComponent={ - query.trim() && !workspacesReady ? null : ( - - - {query.trim() - ? "No workspaces match your search" - : "Search your workspaces by name, branch, or project"} - - - ) - } - renderItem={({ item }) => ( - - router.push(`/(authenticated)/workspace/${item.id}/chat`) - } - > - - {item.name} - - - {item.branch} - - - )} - /> - - ); -} diff --git a/apps/mobile/screens/(authenticated)/(tabs)/(search)/index.ts b/apps/mobile/screens/(authenticated)/(tabs)/(search)/index.ts deleted file mode 100644 index d63b9a71d8b..00000000000 --- a/apps/mobile/screens/(authenticated)/(tabs)/(search)/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { SearchScreen } from "./SearchScreen"; diff --git a/apps/mobile/screens/(authenticated)/(tabs)/(tasks)/[id]/TaskDetailScreen.tsx b/apps/mobile/screens/(authenticated)/(tabs)/(tasks)/[id]/TaskDetailScreen.tsx deleted file mode 100644 index eb319a298ee..00000000000 --- a/apps/mobile/screens/(authenticated)/(tabs)/(tasks)/[id]/TaskDetailScreen.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { useLocalSearchParams, useRouter } from "expo-router"; -import { ChevronLeft } from "lucide-react-native"; -import { Pressable, ScrollView, View } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { Icon } from "@/components/ui/icon"; -import { Text } from "@/components/ui/text"; - -export function TaskDetailScreen() { - const { id } = useLocalSearchParams<{ id: string }>(); - const router = useRouter(); - const insets = useSafeAreaInsets(); - - return ( - - - - router.back()} className="p-1"> - - - Task - - - ID: {id} - - - - Task content will appear here - - - - - ); -} diff --git a/apps/mobile/screens/(authenticated)/(tabs)/(tasks)/[id]/index.ts b/apps/mobile/screens/(authenticated)/(tabs)/(tasks)/[id]/index.ts deleted file mode 100644 index 1dbe96391d9..00000000000 --- a/apps/mobile/screens/(authenticated)/(tabs)/(tasks)/[id]/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { TaskDetailScreen } from "./TaskDetailScreen"; diff --git a/apps/mobile/screens/(authenticated)/(tabs)/(tasks)/tasks/TasksScreen.tsx b/apps/mobile/screens/(authenticated)/(tabs)/(tasks)/tasks/TasksScreen.tsx deleted file mode 100644 index 27efda58021..00000000000 --- a/apps/mobile/screens/(authenticated)/(tabs)/(tasks)/tasks/TasksScreen.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { useCallback, useState } from "react"; -import { RefreshControl, ScrollView, View } from "react-native"; -import { Text } from "@/components/ui/text"; - -export function TasksScreen() { - const [refreshing, setRefreshing] = useState(false); - - const onRefresh = useCallback(async () => { - setRefreshing(true); - // TODO: refresh task data - setRefreshing(false); - }, []); - - return ( - - } - > - - - - Tasks synced via Electric will appear here - - - - - ); -} diff --git a/apps/mobile/screens/(authenticated)/(tabs)/(tasks)/tasks/index.ts b/apps/mobile/screens/(authenticated)/(tabs)/(tasks)/tasks/index.ts deleted file mode 100644 index 7c9a351cd92..00000000000 --- a/apps/mobile/screens/(authenticated)/(tabs)/(tasks)/tasks/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { TasksScreen } from "./TasksScreen"; diff --git a/apps/mobile/screens/(authenticated)/components/HostStatusDot/HostStatusDot.tsx b/apps/mobile/screens/(authenticated)/components/HostStatusDot/HostStatusDot.tsx new file mode 100644 index 00000000000..73508299860 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/components/HostStatusDot/HostStatusDot.tsx @@ -0,0 +1,16 @@ +import { View } from "react-native"; +import { useTheme } from "@/hooks/useTheme"; + +const ONLINE_COLOR = "#3fb950"; + +export function HostStatusDot({ isOnline }: { isOnline: boolean }) { + const theme = useTheme(); + return ( + + ); +} diff --git a/apps/mobile/screens/(authenticated)/components/HostStatusDot/index.ts b/apps/mobile/screens/(authenticated)/components/HostStatusDot/index.ts new file mode 100644 index 00000000000..02323bdb9c7 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/components/HostStatusDot/index.ts @@ -0,0 +1 @@ +export { HostStatusDot } from "./HostStatusDot"; diff --git a/apps/mobile/screens/(authenticated)/components/ListRow/ListRow.tsx b/apps/mobile/screens/(authenticated)/components/ListRow/ListRow.tsx new file mode 100644 index 00000000000..b511cca4d36 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/components/ListRow/ListRow.tsx @@ -0,0 +1,63 @@ +import type { ReactNode } from "react"; +import { Pressable, StyleSheet, View } from "react-native"; +import { Text } from "@/components/ui/text"; +import { useTheme } from "@/hooks/useTheme"; + +export function ListRow({ + icon, + label, + subtitle, + trailing, + onPress, + isLast, + destructive, +}: { + icon?: ReactNode; + label: string; + subtitle?: string; + trailing?: ReactNode; + onPress?: () => void; + isLast?: boolean; + destructive?: boolean; +}) { + const theme = useTheme(); + return ( + + {icon ? {icon} : null} + + + {label} + + {subtitle ? ( + + {subtitle} + + ) : null} + + + {trailing} + + + ); +} diff --git a/apps/mobile/screens/(authenticated)/components/ListRow/index.ts b/apps/mobile/screens/(authenticated)/components/ListRow/index.ts new file mode 100644 index 00000000000..70e023bfe2c --- /dev/null +++ b/apps/mobile/screens/(authenticated)/components/ListRow/index.ts @@ -0,0 +1 @@ +export { ListRow } from "./ListRow"; diff --git a/apps/mobile/screens/(authenticated)/components/ListRowCheck/ListRowCheck.tsx b/apps/mobile/screens/(authenticated)/components/ListRowCheck/ListRowCheck.tsx new file mode 100644 index 00000000000..265a691ddf0 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/components/ListRowCheck/ListRowCheck.tsx @@ -0,0 +1,9 @@ +import Ionicons from "@expo/vector-icons/Ionicons"; +import { useTheme } from "@/hooks/useTheme"; + +export function ListRowCheck({ visible }: { visible: boolean }) { + const theme = useTheme(); + return visible ? ( + + ) : null; +} diff --git a/apps/mobile/screens/(authenticated)/components/ListRowCheck/index.ts b/apps/mobile/screens/(authenticated)/components/ListRowCheck/index.ts new file mode 100644 index 00000000000..d0e1e516f89 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/components/ListRowCheck/index.ts @@ -0,0 +1 @@ +export { ListRowCheck } from "./ListRowCheck"; diff --git a/apps/mobile/screens/(authenticated)/components/ListRowValue/ListRowValue.tsx b/apps/mobile/screens/(authenticated)/components/ListRowValue/ListRowValue.tsx new file mode 100644 index 00000000000..ba9966e69a5 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/components/ListRowValue/ListRowValue.tsx @@ -0,0 +1,35 @@ +import Ionicons from "@expo/vector-icons/Ionicons"; +import type { ReactNode } from "react"; +import { Text } from "@/components/ui/text"; +import { useTheme } from "@/hooks/useTheme"; + +export function ListRowValue({ + value, + accessory, + chevron = true, +}: { + value: string; + accessory?: ReactNode; + chevron?: boolean; +}) { + const theme = useTheme(); + return ( + <> + {accessory} + + {value} + + {chevron ? ( + + ) : null} + + ); +} diff --git a/apps/mobile/screens/(authenticated)/components/ListRowValue/index.ts b/apps/mobile/screens/(authenticated)/components/ListRowValue/index.ts new file mode 100644 index 00000000000..27ffe20ca67 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/components/ListRowValue/index.ts @@ -0,0 +1 @@ +export { ListRowValue } from "./ListRowValue"; diff --git a/apps/mobile/screens/(authenticated)/components/OrgDropdown/OrgDropdown.tsx b/apps/mobile/screens/(authenticated)/components/OrgDropdown/OrgDropdown.tsx deleted file mode 100644 index 32da986446e..00000000000 --- a/apps/mobile/screens/(authenticated)/components/OrgDropdown/OrgDropdown.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { useLiveQuery } from "@tanstack/react-db"; -import { useRouter } from "expo-router"; -import { - ArrowLeftRight, - ChevronDown, - LogOut, - Settings, - UserPlus, -} from "lucide-react-native"; -import { useState } from "react"; -import { View } from "react-native"; -import { Avatar, AvatarFallback } from "@/components/ui/avatar"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Icon } from "@/components/ui/icon"; -import { Text } from "@/components/ui/text"; -import { useSignOut } from "@/hooks/useSignOut"; -import { authClient } from "@/lib/auth/client"; -import { useCollections } from "@/screens/(authenticated)/providers/CollectionsProvider"; - -export function OrgDropdown() { - const router = useRouter(); - const { signOut } = useSignOut(); - const collections = useCollections(); - const [switching, setSwitching] = useState(false); - - const session = authClient.useSession(); - const activeOrgId = session.data?.session?.activeOrganizationId; - - const { data: orgs } = useLiveQuery( - (q) => q.from({ organizations: collections.organizations }), - [collections], - ); - - const activeOrg = orgs?.find((org) => org.id === activeOrgId); - const orgInitial = activeOrg?.name?.charAt(0).toUpperCase() ?? "?"; - - const handleSwitchOrg = async (orgId: string) => { - if (orgId === activeOrgId) return; - setSwitching(true); - try { - await authClient.organization.setActive({ organizationId: orgId }); - router.replace("/(authenticated)/(tabs)/(home)"); - } catch (error) { - console.error("[org/switch] Failed to switch organization:", error); - } finally { - setSwitching(false); - } - }; - - return ( - - - - - - {orgInitial} - - - - - {activeOrg?.name ?? "Select Organization"} - - - - - - - router.push("/(authenticated)/(tabs)/(more)/settings")} - > - - Settings - - - - Invite members - - - {orgs - ?.filter((org) => org.id !== activeOrgId) - .map((org) => ( - handleSwitchOrg(org.id)} - disabled={switching} - > - - Switch to {org.name} - - ))} - {orgs && orgs.filter((org) => org.id !== activeOrgId).length > 0 && ( - - )} - - - Log out - - - - ); -} diff --git a/apps/mobile/screens/(authenticated)/components/OrgDropdown/index.ts b/apps/mobile/screens/(authenticated)/components/OrgDropdown/index.ts deleted file mode 100644 index 19c7757cef8..00000000000 --- a/apps/mobile/screens/(authenticated)/components/OrgDropdown/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { OrgDropdown } from "./OrgDropdown"; diff --git a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/OrganizationSwitcherSheet/components/OrganizationAvatar/OrganizationAvatar.tsx b/apps/mobile/screens/(authenticated)/components/OrganizationAvatar/OrganizationAvatar.tsx similarity index 100% rename from apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/OrganizationSwitcherSheet/components/OrganizationAvatar/OrganizationAvatar.tsx rename to apps/mobile/screens/(authenticated)/components/OrganizationAvatar/OrganizationAvatar.tsx diff --git a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/OrganizationSwitcherSheet/components/OrganizationAvatar/index.ts b/apps/mobile/screens/(authenticated)/components/OrganizationAvatar/index.ts similarity index 100% rename from apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/OrganizationSwitcherSheet/components/OrganizationAvatar/index.ts rename to apps/mobile/screens/(authenticated)/components/OrganizationAvatar/index.ts diff --git a/apps/mobile/screens/(authenticated)/components/TabBarAccessory/TabBarAccessory.tsx b/apps/mobile/screens/(authenticated)/components/TabBarAccessory/TabBarAccessory.tsx deleted file mode 100644 index e322472447d..00000000000 --- a/apps/mobile/screens/(authenticated)/components/TabBarAccessory/TabBarAccessory.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import Ionicons from "@expo/vector-icons/Ionicons"; -import { useRouter } from "expo-router"; -import { ChevronsUpDown } from "lucide-react-native"; -import { useState } from "react"; -import { Pressable, useWindowDimensions, View } from "react-native"; -import { Text } from "@/components/ui/text"; -import { useTheme } from "@/hooks/useTheme"; -import { OrganizationSwitcherSheet } from "@/screens/(authenticated)/(tabs)/(home)/workspaces/components/OrganizationSwitcherSheet"; -import { OrganizationAvatar } from "@/screens/(authenticated)/(tabs)/(home)/workspaces/components/OrganizationSwitcherSheet/components/OrganizationAvatar"; -import { useOrganizations } from "@/screens/(authenticated)/hooks/useOrganizations"; - -export function TabBarAccessory() { - const theme = useTheme(); - const router = useRouter(); - const { width } = useWindowDimensions(); - const [sheetOpen, setSheetOpen] = useState(false); - const { - organizations, - activeOrganization, - activeOrganizationId, - switchOrganization, - } = useOrganizations(); - - const handleSwitchOrganization = (organizationId: string) => { - setSheetOpen(false); - switchOrganization(organizationId); - }; - - return ( - <> - - setSheetOpen(true)} - className="flex-row items-center gap-2" - > - - - {activeOrganization?.name ?? "Organization"} - - - - router.push("/(authenticated)/(tabs)/(more)/settings")} - hitSlop={8} - > - - - - - - ); -} diff --git a/apps/mobile/screens/(authenticated)/components/TabBarAccessory/index.ts b/apps/mobile/screens/(authenticated)/components/TabBarAccessory/index.ts deleted file mode 100644 index e18d1582791..00000000000 --- a/apps/mobile/screens/(authenticated)/components/TabBarAccessory/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { TabBarAccessory } from "./TabBarAccessory"; diff --git a/apps/mobile/screens/(authenticated)/hooks/useOrganizations/useOrganizations.ts b/apps/mobile/screens/(authenticated)/hooks/useOrganizations/useOrganizations.ts index eac0c982036..6cbb8e48974 100644 --- a/apps/mobile/screens/(authenticated)/hooks/useOrganizations/useOrganizations.ts +++ b/apps/mobile/screens/(authenticated)/hooks/useOrganizations/useOrganizations.ts @@ -23,7 +23,7 @@ export function useOrganizations() { if (organizationId === activeOrganizationId) return; try { await authClient.organization.setActive({ organizationId }); - router.replace("/(authenticated)/(tabs)/(home)"); + router.replace("/(authenticated)/(home)"); } catch (error) { console.error( "[organization/switch] Failed to switch organization:", diff --git a/apps/mobile/screens/(authenticated)/settings/SettingsScreen.tsx b/apps/mobile/screens/(authenticated)/settings/SettingsScreen.tsx new file mode 100644 index 00000000000..80d3be432d3 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/settings/SettingsScreen.tsx @@ -0,0 +1,137 @@ +import Ionicons from "@expo/vector-icons/Ionicons"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useRouter } from "expo-router"; +import { Alert, ScrollView, View } from "react-native"; +import { useSignOut } from "@/hooks/useSignOut"; +import { useTheme } from "@/hooks/useTheme"; +import { useSession } from "@/lib/auth/client"; +import { ListRow } from "@/screens/(authenticated)/components/ListRow"; +import { ListRowValue } from "@/screens/(authenticated)/components/ListRowValue"; +import { OrganizationAvatar } from "@/screens/(authenticated)/components/OrganizationAvatar"; +import { useOrganizations } from "@/screens/(authenticated)/hooks/useOrganizations"; +import { useCollections } from "@/screens/(authenticated)/providers/CollectionsProvider"; +import { UserAvatar } from "./components/UserAvatar"; +import { activeSubscription } from "./utils/activeSubscription"; + +export function SettingsScreen() { + const router = useRouter(); + const theme = useTheme(); + const collections = useCollections(); + const { data: session } = useSession(); + const { activeOrganization } = useOrganizations(); + const { signOut, isSigningOut } = useSignOut(); + + const { data: subscriptions } = useLiveQuery( + (q) => q.from({ subscriptions: collections.subscriptions }), + [collections], + ); + + const plan = activeSubscription(subscriptions ?? [])?.plan; + const user = session?.user; + + const handleSignOut = () => { + Alert.alert("Log out?", undefined, [ + { style: "cancel", text: "Cancel" }, + { + onPress: () => void signOut(), + style: "destructive", + text: "Log out", + }, + ]); + }; + + return ( + + } + label={user?.name ?? "Account"} + subtitle={user?.email} + trailing={ + + } + onPress={() => router.push("/(authenticated)/settings/account")} + /> + + } + label="Organization" + trailing={ + + ) : undefined + } + /> + } + onPress={() => router.push("/(authenticated)/settings/organization")} + /> + + } + label="Hosts" + trailing={ + + } + onPress={() => router.push("/(authenticated)/settings/hosts")} + /> + + } + label="Billing" + trailing={ + + } + onPress={() => router.push("/(authenticated)/settings/billing")} + isLast + /> + + + } + label="Log out" + destructive + onPress={isSigningOut ? undefined : handleSignOut} + isLast + /> + + ); +} diff --git a/apps/mobile/screens/(authenticated)/settings/account/AccountSettingsScreen.tsx b/apps/mobile/screens/(authenticated)/settings/account/AccountSettingsScreen.tsx new file mode 100644 index 00000000000..4dc92ebf549 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/settings/account/AccountSettingsScreen.tsx @@ -0,0 +1,47 @@ +import { ScrollView, View } from "react-native"; +import { Text } from "@/components/ui/text"; +import { useTheme } from "@/hooks/useTheme"; +import { useSession } from "@/lib/auth/client"; +import { ListRow } from "@/screens/(authenticated)/components/ListRow"; +import { ListRowValue } from "@/screens/(authenticated)/components/ListRowValue"; +import { UserAvatar } from "../components/UserAvatar"; + +export function AccountSettingsScreen() { + const theme = useTheme(); + const { data: session } = useSession(); + const user = session?.user; + + return ( + + + + + {user?.name} + + + {user?.email} + + + } + /> + } + isLast + /> + + ); +} diff --git a/apps/mobile/screens/(authenticated)/settings/account/index.ts b/apps/mobile/screens/(authenticated)/settings/account/index.ts new file mode 100644 index 00000000000..45638c6b591 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/settings/account/index.ts @@ -0,0 +1 @@ +export { AccountSettingsScreen } from "./AccountSettingsScreen"; diff --git a/apps/mobile/screens/(authenticated)/settings/billing/BillingSettingsScreen.tsx b/apps/mobile/screens/(authenticated)/settings/billing/BillingSettingsScreen.tsx new file mode 100644 index 00000000000..46f00a4b25e --- /dev/null +++ b/apps/mobile/screens/(authenticated)/settings/billing/BillingSettingsScreen.tsx @@ -0,0 +1,84 @@ +import { useLiveQuery } from "@tanstack/react-db"; +import { ScrollView, Text, View } from "react-native"; +import { useTheme } from "@/hooks/useTheme"; +import { ListRow } from "@/screens/(authenticated)/components/ListRow"; +import { ListRowValue } from "@/screens/(authenticated)/components/ListRowValue"; +import { useCollections } from "@/screens/(authenticated)/providers/CollectionsProvider"; +import { activeSubscription } from "../utils/activeSubscription"; + +export function BillingSettingsScreen() { + const theme = useTheme(); + const collections = useCollections(); + + const { data: subscriptions } = useLiveQuery( + (q) => q.from({ subscriptions: collections.subscriptions }), + [collections], + ); + + const subscription = activeSubscription(subscriptions ?? []); + + return ( + + {subscription ? ( + <> + + } + /> + + } + /> + {subscription.seats != null ? ( + + } + /> + ) : null} + {subscription.periodEnd ? ( + + } + isLast + /> + ) : null} + + ) : ( + + + Free plan + + + )} + + Manage billing from the desktop app. + + + ); +} diff --git a/apps/mobile/screens/(authenticated)/settings/billing/index.ts b/apps/mobile/screens/(authenticated)/settings/billing/index.ts new file mode 100644 index 00000000000..e9b819077c3 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/settings/billing/index.ts @@ -0,0 +1 @@ +export { BillingSettingsScreen } from "./BillingSettingsScreen"; diff --git a/apps/mobile/screens/(authenticated)/settings/components/UserAvatar/UserAvatar.tsx b/apps/mobile/screens/(authenticated)/settings/components/UserAvatar/UserAvatar.tsx new file mode 100644 index 00000000000..0ea0f5848c0 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/settings/components/UserAvatar/UserAvatar.tsx @@ -0,0 +1,30 @@ +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Text } from "@/components/ui/text"; +import { cn } from "@/lib/utils"; + +export function UserAvatar({ + name, + image, + className, + textClassName, +}: { + name: string; + image?: string | null; + className?: string; + textClassName?: string; +}) { + const initials = name + .split(" ") + .map((part) => part[0]) + .slice(0, 2) + .join("") + .toUpperCase(); + return ( + + {image ? : null} + + {initials} + + + ); +} diff --git a/apps/mobile/screens/(authenticated)/settings/components/UserAvatar/index.ts b/apps/mobile/screens/(authenticated)/settings/components/UserAvatar/index.ts new file mode 100644 index 00000000000..181000414b6 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/settings/components/UserAvatar/index.ts @@ -0,0 +1 @@ +export { UserAvatar } from "./UserAvatar"; diff --git a/apps/mobile/screens/(authenticated)/settings/hosts/HostsSettingsScreen.tsx b/apps/mobile/screens/(authenticated)/settings/hosts/HostsSettingsScreen.tsx new file mode 100644 index 00000000000..a8dbda42dfe --- /dev/null +++ b/apps/mobile/screens/(authenticated)/settings/hosts/HostsSettingsScreen.tsx @@ -0,0 +1,46 @@ +import { useLiveQuery } from "@tanstack/react-db"; +import { formatDistanceToNow } from "date-fns"; +import { useMemo } from "react"; +import { ScrollView, Text } from "react-native"; +import { useTheme } from "@/hooks/useTheme"; +import { HostStatusDot } from "@/screens/(authenticated)/components/HostStatusDot"; +import { ListRow } from "@/screens/(authenticated)/components/ListRow"; +import { useCollections } from "@/screens/(authenticated)/providers/CollectionsProvider"; + +export function HostsSettingsScreen() { + const theme = useTheme(); + const collections = useCollections(); + + const { data: hosts } = useLiveQuery( + (q) => q.from({ v2Hosts: collections.v2Hosts }), + [collections], + ); + + const hostRows = useMemo( + () => [...(hosts ?? [])].sort((a, b) => a.name.localeCompare(b.name)), + [hosts], + ); + + return ( + + {hostRows.map((host, index) => ( + } + label={host.name} + trailing={ + + {host.isOnline + ? "Online" + : `Last seen ${formatDistanceToNow(host.updatedAt, { addSuffix: true })}`} + + } + isLast={index === hostRows.length - 1} + /> + ))} + + ); +} diff --git a/apps/mobile/screens/(authenticated)/settings/hosts/index.ts b/apps/mobile/screens/(authenticated)/settings/hosts/index.ts new file mode 100644 index 00000000000..a1f03d07d5f --- /dev/null +++ b/apps/mobile/screens/(authenticated)/settings/hosts/index.ts @@ -0,0 +1 @@ +export { HostsSettingsScreen } from "./HostsSettingsScreen"; diff --git a/apps/mobile/screens/(authenticated)/(tabs)/(more)/settings/index.ts b/apps/mobile/screens/(authenticated)/settings/index.ts similarity index 100% rename from apps/mobile/screens/(authenticated)/(tabs)/(more)/settings/index.ts rename to apps/mobile/screens/(authenticated)/settings/index.ts diff --git a/apps/mobile/screens/(authenticated)/settings/organization/OrganizationSettingsScreen.tsx b/apps/mobile/screens/(authenticated)/settings/organization/OrganizationSettingsScreen.tsx new file mode 100644 index 00000000000..b49223fe837 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/settings/organization/OrganizationSettingsScreen.tsx @@ -0,0 +1,89 @@ +import { useLiveQuery } from "@tanstack/react-db"; +import { useMemo } from "react"; +import { ScrollView, View } from "react-native"; +import { Text } from "@/components/ui/text"; +import { useTheme } from "@/hooks/useTheme"; +import { ListRow } from "@/screens/(authenticated)/components/ListRow"; +import { OrganizationAvatar } from "@/screens/(authenticated)/components/OrganizationAvatar"; +import { useOrganizations } from "@/screens/(authenticated)/hooks/useOrganizations"; +import { useCollections } from "@/screens/(authenticated)/providers/CollectionsProvider"; +import { UserAvatar } from "../components/UserAvatar"; + +export function OrganizationSettingsScreen() { + const theme = useTheme(); + const collections = useCollections(); + const { activeOrganization } = useOrganizations(); + + const { data: members } = useLiveQuery( + (q) => q.from({ members: collections.members }), + [collections], + ); + const { data: users } = useLiveQuery( + (q) => q.from({ users: collections.users }), + [collections], + ); + + const memberRows = useMemo(() => { + const usersById = new Map((users ?? []).map((user) => [user.id, user])); + return (members ?? []) + .map((member) => ({ + id: member.id, + role: member.role, + user: usersById.get(member.userId), + })) + .filter((row) => row.user) + .sort((a, b) => (a.user?.name ?? "").localeCompare(b.user?.name ?? "")); + }, [members, users]); + + return ( + + + + + {activeOrganization?.name} + + {activeOrganization?.slug ? ( + + {activeOrganization.slug} + + ) : null} + + + Members + + {memberRows.map((row, index) => ( + + } + label={row.user?.name ?? ""} + subtitle={row.user?.email} + trailing={ + + {row.role[0].toUpperCase() + row.role.slice(1)} + + } + isLast={index === memberRows.length - 1} + /> + ))} + + ); +} diff --git a/apps/mobile/screens/(authenticated)/settings/organization/index.ts b/apps/mobile/screens/(authenticated)/settings/organization/index.ts new file mode 100644 index 00000000000..a8540707298 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/settings/organization/index.ts @@ -0,0 +1 @@ +export { OrganizationSettingsScreen } from "./OrganizationSettingsScreen"; diff --git a/apps/mobile/screens/(authenticated)/settings/utils/activeSubscription/activeSubscription.ts b/apps/mobile/screens/(authenticated)/settings/utils/activeSubscription/activeSubscription.ts new file mode 100644 index 00000000000..21526681eac --- /dev/null +++ b/apps/mobile/screens/(authenticated)/settings/utils/activeSubscription/activeSubscription.ts @@ -0,0 +1,11 @@ +import type { SelectSubscription } from "@superset/db/schema"; + +const ACTIVE_STATUSES = new Set(["active", "trialing"]); + +export function activeSubscription( + subscriptions: SelectSubscription[], +): SelectSubscription | undefined { + return subscriptions + .filter((subscription) => ACTIVE_STATUSES.has(subscription.status)) + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0]; +} diff --git a/apps/mobile/screens/(authenticated)/settings/utils/activeSubscription/index.ts b/apps/mobile/screens/(authenticated)/settings/utils/activeSubscription/index.ts new file mode 100644 index 00000000000..8fb45fb782a --- /dev/null +++ b/apps/mobile/screens/(authenticated)/settings/utils/activeSubscription/index.ts @@ -0,0 +1 @@ +export { activeSubscription } from "./activeSubscription"; diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/hooks/useChatThread/useChatThread.ts b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/hooks/useChatThread/useChatThread.ts index 2edb6419deb..7deaf5d36cb 100644 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/hooks/useChatThread/useChatThread.ts +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/hooks/useChatThread/useChatThread.ts @@ -1,6 +1,6 @@ -import { useLiveQuery } from "@tanstack/react-db"; import { useMutation, useQuery } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useWorkspaceHost } from "@/hooks/useWorkspaceHost"; import type { ChatMessage, ChatMessagePart, @@ -8,7 +8,6 @@ import type { SendMessagePayload, } from "@/lib/trpc/host-chat-types"; import { createHostClient } from "@/lib/trpc/host-client"; -import { useCollections } from "@/screens/(authenticated)/providers/CollectionsProvider"; export type { ChatMessage } from "@/lib/trpc/host-chat-types"; @@ -90,27 +89,11 @@ export function useChatThread(params: { workspaceId: string; }): UseChatThreadResult { const { sessionId, workspaceId } = params; - const collections = useCollections(); - - const { data: workspaces, isReady: workspacesReady } = useLiveQuery( - (q) => q.from({ v2Workspaces: collections.v2Workspaces }), - [collections], - ); - const { data: hosts } = useLiveQuery( - (q) => q.from({ v2Hosts: collections.v2Hosts }), - [collections], - ); - - const workspace = - (workspaces ?? []).find((w) => w.id === workspaceId) ?? null; + const { workspace, host, isResolving } = useWorkspaceHost(workspaceId); const organizationId = workspace?.organizationId ?? null; const hostId = workspace?.hostId ?? null; - const host = hostId - ? ((hosts ?? []).find((h) => h.machineId === hostId) ?? null) - : null; const hostOnline = host?.isOnline ?? false; - // Cache-first: only "resolving" when we have no row AND aren't ready yet. - const workspaceResolving = !workspace && !workspacesReady; + const workspaceResolving = isResolving; const client = useMemo(() => { if (!organizationId || !hostId) return null; diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/hooks/useWorkspaceTerminalAgents/useWorkspaceTerminalAgents.ts b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/hooks/useWorkspaceTerminalAgents/useWorkspaceTerminalAgents.ts index 29a263099be..9bc3eef9715 100644 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/hooks/useWorkspaceTerminalAgents/useWorkspaceTerminalAgents.ts +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/hooks/useWorkspaceTerminalAgents/useWorkspaceTerminalAgents.ts @@ -1,8 +1,7 @@ -import { useLiveQuery } from "@tanstack/react-db"; import { useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; +import { useWorkspaceHost } from "@/hooks/useWorkspaceHost"; import { createHostClient } from "@/lib/trpc/host-client"; -import { useCollections } from "@/screens/(authenticated)/providers/CollectionsProvider"; // Minimal label map for builtin terminal agents (avoids importing // @superset/shared/agent-catalog, whose transitive deps aren't RN-vetted). @@ -60,23 +59,9 @@ export function useWorkspaceTerminalAgents(workspaceId: string): { rows: TerminalAgentRow[]; hostOnline: boolean; } { - const collections = useCollections(); - const { data: workspaces } = useLiveQuery( - (q) => q.from({ v2Workspaces: collections.v2Workspaces }), - [collections], - ); - const { data: hosts } = useLiveQuery( - (q) => q.from({ v2Hosts: collections.v2Hosts }), - [collections], - ); - - const workspace = - (workspaces ?? []).find((w) => w.id === workspaceId) ?? null; + const { workspace, host } = useWorkspaceHost(workspaceId); const organizationId = workspace?.organizationId ?? null; const hostId = workspace?.hostId ?? null; - const host = hostId - ? ((hosts ?? []).find((h) => h.machineId === hostId) ?? null) - : null; const hostOnline = host?.isOnline ?? false; const client = useMemo(() => { diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/components/WorkspaceBackButton/WorkspaceBackButton.tsx b/apps/mobile/screens/(authenticated)/workspace/[id]/components/WorkspaceBackButton/WorkspaceBackButton.tsx index 6713a0daa27..16a3c27ec8f 100644 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/components/WorkspaceBackButton/WorkspaceBackButton.tsx +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/components/WorkspaceBackButton/WorkspaceBackButton.tsx @@ -8,7 +8,7 @@ export function WorkspaceBackButton() { router.back(); return; } - router.replace("/(authenticated)/(tabs)/(home)"); + router.replace("/(authenticated)/(home)"); }; return ( diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json index aa599a658c9..c128036469c 100644 --- a/apps/mobile/tsconfig.json +++ b/apps/mobile/tsconfig.json @@ -5,6 +5,7 @@ "skipLibCheck": true, "paths": { "@/*": ["./*"], + "@superset/alert-prompt": ["./modules/alert-prompt/src/index.ts"], "@superset/tab-bar": ["./modules/tab-bar/src/index.tsx"] } }, diff --git a/bun.lock b/bun.lock index 34f860b40fd..faa50fc75b7 100644 --- a/bun.lock +++ b/bun.lock @@ -497,6 +497,7 @@ "@shikijs/themes": "4.2.0", "@superset/auth": "workspace:*", "@superset/db": "workspace:*", + "@superset/shared": "workspace:*", "@superset/trpc": "workspace:*", "@tanstack/db": "0.6.10", "@tanstack/electric-db-collection": "0.3.8", @@ -520,9 +521,12 @@ "expo-file-system": "56.0.7", "expo-glass-effect": "56.0.4", "expo-image": "56.0.10", + "expo-image-manipulator": "56.0.17", "expo-image-picker": "56.0.16", "expo-linking": "56.0.13", "expo-localization": "56.0.6", + "expo-media-library": "56.0.6", + "expo-modules-core": "56.0.15", "expo-network": "56.0.5", "expo-router": "56.2.9", "expo-secure-store": "56.0.4", @@ -552,6 +556,7 @@ "tokenlens": "1.3.1", "uniwind": "1.8.0", "zod": "4.3.6", + "zustand": "5.0.12", }, "devDependencies": { "@babel/preset-typescript": "7.29.7", @@ -559,6 +564,8 @@ "@storybook/addon-ondevice-actions": "10.4.2", "@storybook/addon-ondevice-controls": "10.4.2", "@storybook/react-native": "10.4.2", + "@superset/host-service": "workspace:*", + "@trpc/server": "11.16.0", "@types/node": "24.12.0", "@types/react": "19.2.14", "babel-preset-expo": "56.0.11", @@ -4243,6 +4250,8 @@ "expo-image-loader": ["expo-image-loader@56.0.3", "", { "peerDependencies": { "expo": "*" } }, "sha512-JgUo4fUeU1ZC+z8iBFj8v7yoGQnZrLbOVPyNE+DWVrld55F2F6R1ck+rmdm/8TNWLz1LhNQfD7c3XYP1ZikxXA=="], + "expo-image-manipulator": ["expo-image-manipulator@56.0.17", "", { "dependencies": { "expo-image-loader": "~56.0.3" }, "peerDependencies": { "expo": "*" } }, "sha512-/rgDufMgc3y/PLO+Q5XrXHDX6lyesueoAVAIqmLEVBH0mVKgCC5u7O3JcIN3foCEB+8zi1fWDipuhKbWtPKq6w=="], + "expo-image-picker": ["expo-image-picker@56.0.16", "", { "dependencies": { "expo-image-loader": "~56.0.3" }, "peerDependencies": { "expo": "*" } }, "sha512-t7tNtkPsbK4D7kKgd0dNylUVTD2IPNmZIa/MXSzMb+uSm7VyHrHfXt+GVZENFRMi99amBGWBuen5OYESwOp5rw=="], "expo-json-utils": ["expo-json-utils@56.0.0", "", {}, "sha512-lUqyv9aIGDbYTQ5Nux2FnH2/Dz0w5uJ8Pr080eS0StXi2jr5OmuMNErpzUnpfnYOU55xKotd4AHv68PfV/ludg=="], @@ -4257,6 +4266,8 @@ "expo-mcp": ["expo-mcp@0.2.4", "", { "dependencies": { "@expo/mcp-tunnel": "~0.2.3", "@modelcontextprotocol/sdk": "^1.26.0", "debug": "^4.4.3", "glob": "^11.0.3", "jimp-compact": "0.16.1", "resolve-from": "^5.0.0", "ws": "^8.18.3", "xml2js": "^0.6.2", "zod": "^3.25.76", "zx": "^8.8.1" }, "bin": { "expo-mcp": "bin/expo-mcp.mjs" } }, "sha512-rBomlm+085wNa+UF9YC3bXGZR6LlYPfOlUXwKBB5R7+dnASk0VjWFETuxyApdtXw9OItmOsAXolUxrAlEYfqSA=="], + "expo-media-library": ["expo-media-library@56.0.6", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-UsyVcxP7Op9ErFFLW1xImjoKFgKi7XSw8hrCfzf2yIG+OgVb9dsQth0mVRPgfRxdELagsUslXc1QXTiW8dpbaQ=="], + "expo-modules-autolinking": ["expo-modules-autolinking@56.0.15", "", { "dependencies": { "@expo/require-utils": "^56.1.3", "@expo/spawn-async": "^1.8.0", "chalk": "^4.1.0", "commander": "^7.2.0" }, "bin": { "expo-modules-autolinking": "bin/expo-modules-autolinking.js" } }, "sha512-WqpBFwLzn7DsrUkWltIjVmAjwuI1VdQ2jRMlvk1nh2kVadwdJBkSjUBQWRifsEePNhiMT/rFOovBolUU/ARt5w=="], "expo-modules-core": ["expo-modules-core@56.0.15", "", { "dependencies": { "@expo/expo-modules-macros-plugin": "~0.0.9", "expo-modules-jsi": "~56.0.8", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-worklets": "^0.7.4 || ^0.8.0" }, "optionalPeers": ["react-native-worklets"] }, "sha512-XOXuWjtUA/xF8VjMHoRTRxuAmrAeUv8QyASX3h/CpTNS58fOt3stV8EYW7BinJPJyqwV7BZoYV83iN0p2FzyZw=="], diff --git a/packages/chat/src/server/desktop/index.ts b/packages/chat/src/server/desktop/index.ts index 390d2dc1a8d..62cb04fddf6 100644 --- a/packages/chat/src/server/desktop/index.ts +++ b/packages/chat/src/server/desktop/index.ts @@ -14,6 +14,8 @@ export { getOpenAICredentialsFromAuthStorage, } from "./auth/openai"; export { ChatService } from "./chat-service"; +export type { AnthropicEnvVariables } from "./chat-service/anthropic-env-config"; +export type { AuthStatus } from "./chat-service/auth-storage-types"; export type { ChatServiceRouter } from "./router"; export { createChatServiceRouter } from "./router"; export type { SlashCommand } from "./slash-commands"; @@ -21,4 +23,5 @@ export { getSlashCommands, resolveSlashCommand, } from "./slash-commands"; +export type { ResolvedSlashCommand } from "./slash-commands/resolver"; export { generateTitleFromMessage } from "./title-generation"; diff --git a/packages/host-service/docs/interim-router-types.md b/packages/host-service/docs/interim-router-types.md new file mode 100644 index 00000000000..d7a96018db7 --- /dev/null +++ b/packages/host-service/docs/interim-router-types.md @@ -0,0 +1,65 @@ +# INTERIM: compiled router type declarations (`dist-types`) + +**Status: temporary.** This machinery exists so non-Node clients (today: `apps/mobile`) +can consume `AppRouter` without typechecking host-service's Node-flavored source under +their own tsconfig (Expo/RN fails on `.ts` import extensions, its ambient `ProcessEnv`, +and DOM-style timer types — in this package and its workspace deps). + +**End state that replaces it:** the wire contract moves to a runtime-neutral package +(zod schemas + inferred types per procedure; SCP v1 / `packages/chat-protocol` is the +same pattern for chat). Clients consume the contract package; host-service asserts its +router conforms. Once that lands, everything below gets reverted. + +## Every change that enables this (from `feat(host-service): ship compiled router type declarations for cross-runtime consumers`) + +Declaration emit for the router's type graph — the four packages the router types reach: + +| File | Change | +|---|---| +| `packages/host-service/tsconfig.types.json` | new — `emitDeclarationOnly` into `dist-types/` | +| `packages/host-service/package.json` | `build:types` script; **types-only `./router` export** → `./dist-types/trpc/router/router.d.ts` | +| `packages/port-scanner/{tsconfig.types.json,package.json}` | same, **plus `exports.*.types` repointed from `src/*.ts` to `dist-types/*.d.ts`** (affects all consumers, not just mobile) | +| `packages/pty-daemon/{tsconfig.types.json,package.json}` | same as port-scanner | +| `packages/workspace-fs/{tsconfig.types.json,package.json}` | same as port-scanner | +| `turbo.jsonc` | `build:types` task (`outputs: dist-types/**`); `typecheck.dependsOn` gains `^build:types` | +| `.gitignore` | `dist-types` (artifacts are generated, never committed) | + +Nameability shims — declaration emit can't reference unexported types (TS4023/TS2742), +so these were exported solely for the emit: + +| File | Change | +|---|---| +| `packages/host-service/src/trpc/index.ts` | `RouterMeta` exported | +| `packages/host-service/src/trpc/router/project/handlers.ts` | `CreateResult` exported | +| `packages/host-service/src/trpc/router/workspace-creation/procedures/search-github-issues.ts` | result type exported | +| `packages/host-service/src/trpc/router/workspace-creation/procedures/search-pull-requests.ts` | result type exported | +| `packages/chat/src/server/desktop/index.ts` | re-exports `AnthropicEnvVariables`, `AuthStatus`, `ResolvedSlashCommand` | + +Consumer side: + +| File | Change | +|---|---| +| `apps/mobile/package.json` | deps on `@superset/host-service` (type-only) and `@trpc/server` (for `inferRouterOutputs`) | +| `apps/mobile/lib/host-service/client.ts` | `import type { AppRouter } from "@superset/host-service/router"`; output aliases via `inferRouterOutputs` | + +## Known costs (why this isn't the end state) + +- IDE types go stale until `build:types` reruns (turbo covers CI, not your editor). +- Four packages carry build steps whose only consumer is mobile's typecheck. +- The `exports.types` repointing in port-scanner/pty-daemon/workspace-fs changed what + *desktop and every other consumer* typecheck against too. +- Server-internal types (mastra harness types in the chat router) still cross the wire. + +## Revert checklist (do this when the contract package lands) + +1. Point `apps/mobile/lib/host-service/client.ts` at the contract package's types; + drop mobile's `@superset/host-service` and `@trpc/server` deps. +2. Delete the four `tsconfig.types.json` files and their `build:types` scripts. +3. Restore `exports.*.types` in port-scanner/pty-daemon/workspace-fs to the `src/*.ts` + paths (see the commit named above for the originals). +4. Remove the `./router` export from `packages/host-service/package.json`. +5. Remove `build:types` from `turbo.jsonc` and `^build:types` from `typecheck.dependsOn`; + drop `dist-types` from `.gitignore`. +6. The nameability `export type` shims are harmless to keep, but no longer required. +7. `grep -rn "dist-types\|build:types\|host-service/router" --include="*.json" --include="*.ts"` + should come back empty (this doc excepted). diff --git a/packages/host-service/package.json b/packages/host-service/package.json index 8929df588de..4b4e04ddf99 100644 --- a/packages/host-service/package.json +++ b/packages/host-service/package.json @@ -40,12 +40,16 @@ "types": "./src/trpc/router/attachments/index.ts", "default": "./src/trpc/router/attachments/index.ts" }, + "./router": { + "types": "./dist-types/trpc/router/router.d.ts" + }, "./package.json": "./package.json" }, "scripts": { "clean": "git clean -xdf .cache .turbo dist node_modules", "dev": "bun run src/serve.ts", "build:host": "bun run build.ts", + "build:types": "tsc -p tsconfig.types.json", "generate": "drizzle-kit generate", "test": "bun test --pass-with-no-tests", "test:integration": "bun test --pass-with-no-tests test/integration", diff --git a/packages/host-service/src/trpc/index.ts b/packages/host-service/src/trpc/index.ts index c469ac1cffa..dd899df6f29 100644 --- a/packages/host-service/src/trpc/index.ts +++ b/packages/host-service/src/trpc/index.ts @@ -10,7 +10,7 @@ import { type TeardownFailureCause, } from "./error-types"; -interface RouterMeta { +export interface RouterMeta { /** * Per-procedure timeout in milliseconds, applied to query procedures * via `queryProcedure`. Defaults to 5_000 when omitted. Set higher for @@ -119,4 +119,5 @@ export type { ProjectNotSetupCause, TeardownFailureCause, } from "./error-types"; +// INTERIM cross-runtime types via dist-types — see docs/interim-router-types.md export type { AppRouter } from "./router"; diff --git a/packages/host-service/src/trpc/router/project/handlers.ts b/packages/host-service/src/trpc/router/project/handlers.ts index 73daf146c28..ffcb3691dac 100644 --- a/packages/host-service/src/trpc/router/project/handlers.ts +++ b/packages/host-service/src/trpc/router/project/handlers.ts @@ -45,7 +45,7 @@ function dirNameForEmpty(name: string): string { return slug; } -interface CreateResult { +export interface CreateResult { projectId: string; repoPath: string; mainWorkspaceId: string; diff --git a/packages/host-service/src/trpc/router/workspace-creation/procedures/search-github-issues.ts b/packages/host-service/src/trpc/router/workspace-creation/procedures/search-github-issues.ts index 0831d9a66f3..0ab1940c59e 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/procedures/search-github-issues.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/procedures/search-github-issues.ts @@ -16,7 +16,7 @@ interface IssueResult { authorLogin: string | null; } -interface IssuesPage { +export interface IssuesPage { issues: IssueResult[]; totalCount: number; hasNextPage: boolean; diff --git a/packages/host-service/src/trpc/router/workspace-creation/procedures/search-pull-requests.ts b/packages/host-service/src/trpc/router/workspace-creation/procedures/search-pull-requests.ts index a6c537decd1..7fa5d6ac1f6 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/procedures/search-pull-requests.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/procedures/search-pull-requests.ts @@ -17,7 +17,7 @@ interface PullRequestResult { authorLogin: string | null; } -interface PullRequestsPage { +export interface PullRequestsPage { pullRequests: PullRequestResult[]; totalCount: number; hasNextPage: boolean; diff --git a/packages/host-service/tsconfig.types.json b/packages/host-service/tsconfig.types.json new file mode 100644 index 00000000000..a884d08836b --- /dev/null +++ b/packages/host-service/tsconfig.types.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "rootDir": "src", + "outDir": "dist-types" + }, + "include": ["src"] +} diff --git a/packages/port-scanner/package.json b/packages/port-scanner/package.json index 324eb618ba3..3c602136cac 100644 --- a/packages/port-scanner/package.json +++ b/packages/port-scanner/package.json @@ -5,14 +5,15 @@ "type": "module", "exports": { ".": { - "types": "./src/index.ts", + "types": "./dist-types/index.d.ts", "default": "./src/index.ts" } }, "scripts": { "clean": "git clean -xdf .cache .turbo dist node_modules", "typecheck": "tsc --noEmit --emitDeclarationOnly false", - "test": "bun test" + "test": "bun test", + "build:types": "tsc -p tsconfig.types.json" }, "dependencies": { "pidtree": "0.6.0" diff --git a/packages/port-scanner/tsconfig.types.json b/packages/port-scanner/tsconfig.types.json new file mode 100644 index 00000000000..a884d08836b --- /dev/null +++ b/packages/port-scanner/tsconfig.types.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "rootDir": "src", + "outDir": "dist-types" + }, + "include": ["src"] +} diff --git a/packages/pty-daemon/package.json b/packages/pty-daemon/package.json index 5d06b3ddeb2..2957b49130e 100644 --- a/packages/pty-daemon/package.json +++ b/packages/pty-daemon/package.json @@ -5,15 +5,15 @@ "type": "module", "exports": { ".": { - "types": "./src/index.ts", + "types": "./dist-types/index.d.ts", "default": "./src/index.ts" }, "./protocol": { - "types": "./src/protocol/index.ts", + "types": "./dist-types/protocol/index.d.ts", "default": "./src/protocol/index.ts" }, "./process-tree": { - "types": "./src/process-tree.ts", + "types": "./dist-types/process-tree.d.ts", "default": "./src/process-tree.ts" }, "./package.json": "./package.json" @@ -30,7 +30,8 @@ "build:daemon": "bun run build.ts", "typecheck": "tsc --noEmit --emitDeclarationOnly false", "test": "bun test src/protocol src/SessionStore src/handlers src/Pty/Pty.test.ts test/no-encoding-hops.test.ts", - "test:integration": "node --experimental-strip-types --test test/integration.test.ts test/control-plane.test.ts test/signal-recovery.test.ts test/byte-fidelity.test.ts test/handoff.test.ts" + "test:integration": "node --experimental-strip-types --test test/integration.test.ts test/control-plane.test.ts test/signal-recovery.test.ts test/byte-fidelity.test.ts test/handoff.test.ts", + "build:types": "tsc -p tsconfig.types.json" }, "dependencies": { "node-pty": "1.1.0" diff --git a/packages/pty-daemon/tsconfig.types.json b/packages/pty-daemon/tsconfig.types.json new file mode 100644 index 00000000000..a884d08836b --- /dev/null +++ b/packages/pty-daemon/tsconfig.types.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "rootDir": "src", + "outDir": "dist-types" + }, + "include": ["src"] +} diff --git a/packages/workspace-fs/package.json b/packages/workspace-fs/package.json index 94863b5ed48..06356dbff79 100644 --- a/packages/workspace-fs/package.json +++ b/packages/workspace-fs/package.json @@ -5,30 +5,31 @@ "type": "module", "exports": { "./client": { - "types": "./src/client/index.ts", + "types": "./dist-types/client/index.d.ts", "default": "./src/client/index.ts" }, "./core": { - "types": "./src/core/index.ts", + "types": "./dist-types/core/index.d.ts", "default": "./src/core/index.ts" }, "./server": { - "types": "./src/host/index.ts", + "types": "./dist-types/host/index.d.ts", "default": "./src/host/index.ts" }, "./host": { - "types": "./src/host/index.ts", + "types": "./dist-types/host/index.d.ts", "default": "./src/host/index.ts" }, "./resource-uri": { - "types": "./src/resource-uri.ts", + "types": "./dist-types/resource-uri.d.ts", "default": "./src/resource-uri.ts" } }, "scripts": { "typecheck": "tsc --noEmit --emitDeclarationOnly false", "test": "bun test --pass-with-no-tests", - "bench": "bun test src/cache-and-paths-memory.bench.ts" + "bench": "bun test src/cache-and-paths-memory.bench.ts", + "build:types": "tsc -p tsconfig.types.json" }, "dependencies": { "@parcel/watcher": "2.5.6", diff --git a/packages/workspace-fs/tsconfig.types.json b/packages/workspace-fs/tsconfig.types.json new file mode 100644 index 00000000000..a884d08836b --- /dev/null +++ b/packages/workspace-fs/tsconfig.types.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "rootDir": "src", + "outDir": "dist-types" + }, + "include": ["src"] +} diff --git a/turbo.jsonc b/turbo.jsonc index 125a76a58bd..76d1c3154c2 100644 --- a/turbo.jsonc +++ b/turbo.jsonc @@ -53,8 +53,11 @@ "dependsOn": ["^build"], "outputs": ["coverage/**"] }, + "build:types": { + "outputs": ["dist-types/**"] + }, "typecheck": { - "dependsOn": ["^build"], + "dependsOn": ["^build", "^build:types"], "outputs": [".cache/tsbuildinfo.json"] }, "lint": {