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": {