From c6b97a972b605818ad75a7bf9a9d7dd536bc64c3 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Wed, 8 Jul 2026 12:15:23 -0700 Subject: [PATCH 01/30] feat(mobile): read workspaces via host fan-out; host-owned rename/delete; live diff stats - new host-service tRPC client over the relay (user JWT; no host PSK) with a mobile-declared procedure contract (the real AppRouter type graph is Node-flavored source that can't compile under Expo TS) - useHostWorkspaces: per-host workspace.list fan-out via v2Hosts targets, 30s poll + focus/pull-to-refresh, Electric v2Workspaces as read-only fallback (PR #5452 architecture) - rename/delete now go through the owning host (workspace.update / workspaceCleanup.destroy) with dirty-worktree CONFLICT + teardown-failed force-retry handling; Delete hidden for main workspaces - live working-tree diff stats (git.getStatus, desktop aggregation) for viewport-settled rows only, replacing PR cumulative +/- - PR badge matching now repo-scoped with desktop's state derivation and open> .env success "Workspace .env written" diff --git a/apps/mobile/app/(authenticated)/(tabs)/_layout.tsx b/apps/mobile/app/(authenticated)/(tabs)/_layout.tsx index 10a5c56c0cd..03d10262aba 100644 --- a/apps/mobile/app/(authenticated)/(tabs)/_layout.tsx +++ b/apps/mobile/app/(authenticated)/(tabs)/_layout.tsx @@ -13,21 +13,21 @@ export default function TabsLayout() { - Home + - Tasks + - Search + - More + ); diff --git a/apps/mobile/hooks/useHostWorkspaces/index.ts b/apps/mobile/hooks/useHostWorkspaces/index.ts new file mode 100644 index 00000000000..3a00e92e341 --- /dev/null +++ b/apps/mobile/hooks/useHostWorkspaces/index.ts @@ -0,0 +1,6 @@ +export type { HostWorkspaceItem } from "./useHostWorkspaces"; +export { + type HostWorkspacesCacheOps, + type UseHostWorkspacesResult, + useHostWorkspaces, +} from "./useHostWorkspaces"; diff --git a/apps/mobile/hooks/useHostWorkspaces/useHostWorkspaces.ts b/apps/mobile/hooks/useHostWorkspaces/useHostWorkspaces.ts new file mode 100644 index 00000000000..401eb7c4980 --- /dev/null +++ b/apps/mobile/hooks/useHostWorkspaces/useHostWorkspaces.ts @@ -0,0 +1,139 @@ +import { useLiveQuery } from "@tanstack/react-db"; +import { useQueries, useQueryClient } from "@tanstack/react-query"; +import { useMemo } from "react"; +import { getHostServiceClientByUrl } from "@/lib/host-service/client"; +import { useCollections } from "@/screens/(authenticated)/providers/CollectionsProvider"; +import { + deriveHostWorkspacesQueryTargets, + getHostWorkspacesQueryKey, + type HostWorkspaceItem, + type HostWorkspaceRow, + mergeHostWorkspaces, +} from "./useHostWorkspaces.utils"; + +export type { HostWorkspaceItem } from "./useHostWorkspaces.utils"; + +const WORKSPACES_REFETCH_INTERVAL_MS = 30_000; + +export interface HostWorkspacesCacheOps { + /** Resolve the URL to reach the host owning `hostId` (null = unreachable). */ + resolveHostUrl: (hostId: string) => string | null; + /** Optimistically upsert a row into a host's cached list. */ + upsertWorkspace: (row: HostWorkspaceRow) => void; + /** Optimistically drop a row from a host's cached list. */ + removeWorkspace: (hostId: string, workspaceId: string) => void; + /** Rollback hammer: refetch a host's list after a failed write. */ + invalidateHost: (hostId: string) => void; +} + +export interface UseHostWorkspacesResult { + workspaces: HostWorkspaceItem[]; + /** + * True once every host answered or failed. Gates empty states only — + * existing rows always render (cache-first rule). + */ + isReady: boolean; + cache: HostWorkspacesCacheOps; +} + +/** + * The workspace read path (mobile port of desktop's useHostWorkspaces): + * fan out `workspace.list` to every online host via the relay, merge, and + * fall back to the Electric cloud collection for hosts that served + * nothing. No local host, no event bus — the 30s poll plus focus/pull + * refetch is the healing path. + */ +export function useHostWorkspaces(): UseHostWorkspacesResult { + const collections = useCollections(); + const queryClient = useQueryClient(); + + const { data: hosts } = useLiveQuery( + (q) => q.from({ v2Hosts: collections.v2Hosts }), + [collections], + ); + const { data: cloudRows } = useLiveQuery( + (q) => q.from({ v2Workspaces: collections.v2Workspaces }), + [collections], + ); + + const targets = useMemo( + () => deriveHostWorkspacesQueryTargets(hosts ?? []), + [hosts], + ); + + const queries = useQueries({ + queries: targets.map((target) => ({ + queryKey: getHostWorkspacesQueryKey(target), + enabled: target.hostUrl !== null, + refetchInterval: WORKSPACES_REFETCH_INTERVAL_MS, + retry: 1, + networkMode: "always" as const, + queryFn: async (): Promise => { + if (!target.hostUrl) return []; + return getHostServiceClientByUrl(target.hostUrl).workspace.list.query(); + }, + })), + }); + + const workspaces = useMemo( + () => + mergeHostWorkspaces({ + hostResults: targets.map((target, index) => { + const query = queries[index]; + return { + target, + rows: query?.data, + reachable: query?.data !== undefined && !query?.isError, + }; + }), + cloudRows: cloudRows ?? [], + }), + [targets, queries, cloudRows], + ); + + const isReady = queries.every( + (query, index) => + query.isSuccess || query.isError || targets[index]?.hostUrl === null, + ); + + const cache = useMemo(() => { + const targetFor = (hostId: string) => + targets.find((target) => target.machineId === hostId); + return { + resolveHostUrl: (hostId) => targetFor(hostId)?.hostUrl ?? null, + upsertWorkspace: (row) => { + const target = targetFor(row.hostId); + if (!target) return; + queryClient.setQueryData( + getHostWorkspacesQueryKey(target), + (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) => { + const target = targetFor(hostId); + if (!target) return; + queryClient.setQueryData( + getHostWorkspacesQueryKey(target), + (rows) => rows?.filter((row) => row.id !== workspaceId), + ); + }, + invalidateHost: (hostId) => { + const target = targetFor(hostId); + if (!target) return; + void queryClient.invalidateQueries({ + queryKey: getHostWorkspacesQueryKey(target), + }); + }, + }; + }, [targets, queryClient]); + + return { workspaces, isReady, cache }; +} diff --git a/apps/mobile/hooks/useHostWorkspaces/useHostWorkspaces.utils.ts b/apps/mobile/hooks/useHostWorkspaces/useHostWorkspaces.utils.ts new file mode 100644 index 00000000000..b9bb70bf9ab --- /dev/null +++ b/apps/mobile/hooks/useHostWorkspaces/useHostWorkspaces.utils.ts @@ -0,0 +1,102 @@ +import type { SelectV2Workspace } from "@superset/db/schema"; +import { + buildRelayHostUrl, + type HostWorkspaceRow, +} from "@/lib/host-service/client"; + +export type { HostWorkspaceRow } from "@/lib/host-service/client"; + +export interface HostWorkspaceItem extends SelectV2Workspace { + worktreePath?: string; + worktreeExists?: boolean; + /** False when the row came from the cloud fallback and the host didn't answer. */ + hostReachable: boolean; + /** "host" = served by a host; "cloud" = Electric fallback. */ + source: "host" | "cloud"; +} + +export interface HostWorkspacesQueryTarget { + machineId: string; + organizationId: string; + /** Null when the host is known but offline. */ + hostUrl: string | null; +} + +export interface HostRowForTargets { + organizationId: string; + machineId: string; + isOnline: boolean; +} + +export function getHostWorkspacesQueryKey( + target: Pick, +) { + return [ + "host-service", + "workspaces", + "list", + target.machineId, + target.hostUrl, + ] as const; +} + +export function deriveHostWorkspacesQueryTargets( + hosts: HostRowForTargets[], +): HostWorkspacesQueryTarget[] { + return hosts.map((host) => ({ + machineId: host.machineId, + organizationId: host.organizationId, + hostUrl: host.isOnline + ? buildRelayHostUrl(host.organizationId, host.machineId) + : null, + })); +} + +/** + * Merge per-host results with the Electric fallback. A host that answered + * is authoritative for its rows — cloud rows for that host are ignored (a + * deleted row must not resurrect). Cloud rows only fill in for hosts with + * no host-served data. The fallback is deleted in R3 along with the cloud + * table. + */ +export function mergeHostWorkspaces({ + hostResults, + cloudRows, +}: { + hostResults: Array<{ + target: HostWorkspacesQueryTarget; + rows: HostWorkspaceRow[] | undefined; + reachable: boolean; + }>; + cloudRows: SelectV2Workspace[]; +}): HostWorkspaceItem[] { + const items: HostWorkspaceItem[] = []; + const hostsWithData = new Set(); + const seenIds = new Set(); + + for (const result of hostResults) { + if (!result.rows) continue; + hostsWithData.add(result.target.machineId); + for (const row of result.rows) { + if (seenIds.has(row.id)) continue; + seenIds.add(row.id); + items.push({ + ...row, + hostReachable: result.reachable, + source: "host", + }); + } + } + + for (const row of cloudRows) { + if (seenIds.has(row.id) || hostsWithData.has(row.hostId)) continue; + seenIds.add(row.id); + items.push({ + ...row, + hostReachable: false, + source: "cloud", + }); + } + + return items; +} diff --git a/apps/mobile/lib/host-service/client.ts b/apps/mobile/lib/host-service/client.ts new file mode 100644 index 00000000000..93d23e1ade0 --- /dev/null +++ b/apps/mobile/lib/host-service/client.ts @@ -0,0 +1,91 @@ +import type { SelectV2Workspace } from "@superset/db/schema"; +import { buildHostRoutingKey } from "@superset/shared/host-routing"; +import { createTRPCClient, httpLink } from "@trpc/client"; +import superjson from "superjson"; +import { getJwt } from "../auth/client"; +import { env } from "../env"; + +export interface HostWorkspaceRow extends SelectV2Workspace { + worktreePath: string; + worktreeExists: boolean; +} + +export interface ChangedFileStats { + path: string; + additions: number; + deletions: number; +} + +export interface GitStatusSnapshot { + againstBase: ChangedFileStats[]; + staged: ChangedFileStats[]; + unstaged: ChangedFileStats[]; +} + +export interface DestroyWorkspaceResult { + success: boolean; + cloudDeleted: boolean; + worktreeRemoved: boolean; + branchDeleted: boolean; + warnings: string[]; +} + +// Procedures mirrored from packages/host-service/src/trpc/router — the real +// AppRouter type graph is Node-flavored source that cannot compile under +// Expo's TS environment. Inputs are validated by zod on the host. +export interface HostServiceClient { + workspace: { + list: { query(): Promise }; + update: { + mutate(input: { id: string; name?: string }): Promise; + }; + }; + workspaceCleanup: { + destroy: { + mutate(input: { + workspaceId: string; + deleteBranch?: boolean; + force?: boolean; + }): Promise; + }; + }; + git: { + getStatus: { + query(input: { + workspaceId: string; + priority?: "foreground" | "background"; + }): Promise; + }; + }; +} + +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; + + // biome-ignore lint/suspicious/noExplicitAny: see HostServiceClient + const client = createTRPCClient({ + links: [ + httpLink({ + url: `${hostUrl}/trpc`, + transformer: superjson, + headers: () => { + const jwt = getJwt(); + return jwt ? { Authorization: `Bearer ${jwt}` } : {}; + }, + }), + ], + }) as unknown as HostServiceClient; + + 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..d0cb65ca048 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", diff --git a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/WorkspacesScreen.tsx b/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/WorkspacesScreen.tsx index b07991c9e24..8f843e1db33 100644 --- a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/WorkspacesScreen.tsx +++ b/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/WorkspacesScreen.tsx @@ -1,37 +1,53 @@ import { LegendList } from "@legendapp/list/react-native"; -import type { - SelectGithubPullRequest, - SelectV2Workspace, -} from "@superset/db/schema"; +import type { SelectGithubPullRequest } from "@superset/db/schema"; import { useLiveQuery } from "@tanstack/react-db"; -import { compareDesc } from "date-fns"; -import { Stack, useRouter } from "expo-router"; +import { useQueryClient } from "@tanstack/react-query"; +import { compareDesc, isAfter } from "date-fns"; +import { Stack, useFocusEffect } from "expo-router"; import { useCallback, useMemo, useState } from "react"; -import { Pressable, useWindowDimensions, View } from "react-native"; +import { + Pressable, + RefreshControl, + useWindowDimensions, + View, +} from "react-native"; import { Text } from "@/components/ui/text"; +import { + type HostWorkspaceItem, + useHostWorkspaces, +} from "@/hooks/useHostWorkspaces"; 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 FilterableHost, type FilterableProject, WorkspaceFilterSheet, type WorkspaceSort, } from "./components/WorkspaceFilterSheet"; -import { WorkspaceRow } from "./components/WorkspaceRow"; +import { prStateFor, WorkspaceRow } from "./components/WorkspaceRow"; +import { useVisibleDiffStats } from "./hooks/useVisibleDiffStats"; + +const VIEWABILITY_CONFIG = { + itemVisiblePercentThreshold: 50, + minimumViewTime: 250, +}; + +const MAX_VISIBLE_DIFF_STATS = 20; export function WorkspacesScreen() { - const router = useRouter(); const [sheetOpen, setSheetOpen] = useState(false); const [filterSheetOpen, setFilterSheetOpen] = useState(false); const [projectFilter, setProjectFilter] = useState(null); + const [hostFilter, setHostFilter] = useState(null); const [sort, setSort] = useState("updatedAt"); - const [actionsWorkspace, setActionsWorkspace] = - useState(null); + const [visibleIds, setVisibleIds] = useState([]); + const [refreshing, setRefreshing] = useState(false); const { width } = useWindowDimensions(); const collections = useCollections(); + const queryClient = useQueryClient(); const { organizations, activeOrganization, @@ -39,10 +55,8 @@ export function WorkspacesScreen() { switchOrganization, } = useOrganizations(); - const { data: workspaces, isReady: workspacesReady } = useLiveQuery( - (q) => q.from({ v2Workspaces: collections.v2Workspaces }), - [collections], - ); + const { workspaces, isReady, cache } = useHostWorkspaces(); + const { data: projects } = useLiveQuery( (q) => q.from({ v2Projects: collections.v2Projects }), [collections], @@ -63,7 +77,7 @@ export function WorkspacesScreen() { const filterableProjects = useMemo(() => { const counts = new Map(); - for (const workspace of workspaces ?? []) { + for (const workspace of workspaces) { counts.set( workspace.projectId, (counts.get(workspace.projectId) ?? 0) + 1, @@ -82,43 +96,116 @@ export function WorkspacesScreen() { (project) => project.id === selectedProjectId, ); - const visibleWorkspaces = useMemo(() => { - return (workspaces ?? []) - .filter((workspace) => workspace.projectId === selectedProjectId) + const filterableHosts = useMemo(() => { + const counts = new Map(); + for (const workspace of workspaces) { + if (workspace.projectId !== selectedProjectId) continue; + counts.set(workspace.hostId, (counts.get(workspace.hostId) ?? 0) + 1); + } + return (hosts ?? []) + .map((host) => ({ + machineId: host.machineId, + name: host.name, + isOnline: host.isOnline, + workspaceCount: counts.get(host.machineId) ?? 0, + })) + .sort((a, b) => a.name.localeCompare(b.name)); + }, [hosts, workspaces, selectedProjectId]); + + const visibleWorkspaces = useMemo(() => { + return workspaces + .filter( + (workspace) => + workspace.projectId === selectedProjectId && + (hostFilter === null || workspace.hostId === hostFilter), + ) .sort((a, b) => compareDesc(a[sort], b[sort])); - }, [workspaces, selectedProjectId, sort]); + }, [workspaces, selectedProjectId, hostFilter, sort]); + + const workspacesById = useMemo( + () => new Map(workspaces.map((workspace) => [workspace.id, workspace])), + [workspaces], + ); - const pullRequestsByBranch = useMemo(() => { - const byBranch = new Map(); - const rank = (state: string) => - state === "open" ? 2 : state === "merged" ? 1 : 0; + 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 existing = byBranch.get(pullRequest.headBranch); - if (!existing || rank(pullRequest.state) > rank(existing.state)) { - byBranch.set(pullRequest.headBranch, pullRequest); + 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 byBranch; + return byRepoBranch; }, [pullRequests]); - const hostsById = useMemo( - () => new Map((hosts ?? []).map((host) => [host.machineId, host])), - [hosts], + 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 projectRepositoryId = selectedProject?.githubRepositoryId ?? null; + const renderItem = useCallback( - ({ item }: { item: SelectV2Workspace }) => ( + ({ item }: { item: HostWorkspaceItem }) => ( - router.push(`/(authenticated)/workspace/${item.id}/chat`) + pullRequest={ + projectRepositoryId + ? pullRequestsByRepoBranch.get( + `${projectRepositoryId}::${item.branch}`, + ) + : undefined } - onLongPress={() => setActionsWorkspace(item)} + diffStats={diffStats.get(item.id) ?? null} + cache={cache} /> ), - [pullRequestsByBranch, hostsById, router], + [pullRequestsByRepoBranch, projectRepositoryId, diffStats, cache], ); const handleSwitchOrganization = (organizationId: string) => { @@ -134,7 +221,6 @@ export function WorkspacesScreen() { onPress={() => setSheetOpen(true)} /> - {}} /> item.id} + keyExtractor={(item: HostWorkspaceItem) => item.id} renderItem={renderItem} + viewabilityConfig={VIEWABILITY_CONFIG} + onViewableItemsChanged={onViewableItemsChanged} + refreshControl={ + + } ListEmptyComponent={ - workspacesReady ? ( + isReady ? ( No workspaces in this project yet @@ -184,18 +275,13 @@ export function WorkspacesScreen() { setProjectFilter(projectId); setFilterSheetOpen(false); }} + hosts={filterableHosts} + selectedHostId={hostFilter} + onSelectHost={setHostFilter} 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 index 015659a9471..1c0029982c3 100644 --- a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceFilterSheet/WorkspaceFilterSheet.tsx +++ b/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceFilterSheet/WorkspaceFilterSheet.tsx @@ -19,6 +19,13 @@ export interface FilterableProject { workspaceCount: number; } +export interface FilterableHost { + machineId: string; + name: string; + isOnline: boolean; + workspaceCount: number; +} + const SORT_OPTIONS: { value: WorkspaceSort; label: string }[] = [ { label: "Last updated", value: "updatedAt" }, { label: "Date created", value: "createdAt" }, @@ -30,6 +37,9 @@ export function WorkspaceFilterSheet({ projects, selectedProjectId, onSelectProject, + hosts, + selectedHostId, + onSelectHost, sort, onChangeSort, width, @@ -39,6 +49,9 @@ export function WorkspaceFilterSheet({ projects: FilterableProject[]; selectedProjectId: string | null; onSelectProject: (projectId: string) => void; + hosts: FilterableHost[]; + selectedHostId: string | null; + onSelectHost: (machineId: string | null) => void; sort: WorkspaceSort; onChangeSort: (sort: WorkspaceSort) => void; width: number; @@ -135,6 +148,69 @@ export function WorkspaceFilterSheet({ ); })} + + Host + + onSelectHost(null)} + className="flex-row items-center gap-2.5 py-2.5" + > + + All hosts + + {selectedHostId === null ? ( + + ) : null} + + {hosts.map((host) => { + const isActive = host.machineId === selectedHostId; + return ( + onSelectHost(host.machineId)} + className="flex-row items-center gap-2.5 py-2.5" + > + + + {host.name} + + + {host.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 index 12c36a77b98..3e2aa5db7d3 100644 --- a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceFilterSheet/index.ts +++ b/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceFilterSheet/index.ts @@ -1,4 +1,5 @@ export { + type FilterableHost, type FilterableProject, WorkspaceFilterSheet, type WorkspaceSort, 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 index 3b683a08f2f..192a6aa932c 100644 --- a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceRow/WorkspaceRow.tsx +++ b/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceRow/WorkspaceRow.tsx @@ -1,7 +1,4 @@ -import type { - SelectGithubPullRequest, - SelectV2Workspace, -} from "@superset/db/schema"; +import type { SelectGithubPullRequest } from "@superset/db/schema"; import { formatDistanceToNow } from "date-fns"; import { Circle, @@ -14,6 +11,12 @@ import { import { Linking, Pressable, View } from "react-native"; import { Icon } from "@/components/ui/icon"; import { Text } from "@/components/ui/text"; +import type { + HostWorkspaceItem, + HostWorkspacesCacheOps, +} from "@/hooks/useHostWorkspaces"; +import type { DiffStats } from "../../hooks/useVisibleDiffStats"; +import { WorkspaceRowMenu } from "./components/WorkspaceRowMenu"; const PR_BADGE_CONFIG = { closed: { @@ -38,7 +41,14 @@ const PR_BADGE_CONFIG = { }, } as const; -type PrBadgeState = keyof typeof PR_BADGE_CONFIG; +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"; @@ -46,93 +56,89 @@ const DELETIONS_COLOR = "#f85149"; export function WorkspaceRow({ workspace, pullRequest, - hostOnline, - onPress, - onLongPress, + diffStats, + cache, }: { - workspace: SelectV2Workspace; + workspace: HostWorkspaceItem; pullRequest?: SelectGithubPullRequest; - hostOnline?: boolean; - onPress: () => void; - onLongPress: () => void; + diffStats: DiffStats | null; + cache: HostWorkspacesCacheOps; }) { - 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 prBadge = pullRequest ? PR_BADGE_CONFIG[prStateFor(pullRequest)] : null; const HostIcon = - hostOnline === undefined ? Circle : hostOnline ? Cloud : CloudOff; + workspace.source === "cloud" + ? Circle + : workspace.hostReachable + ? Cloud + : CloudOff; return ( - - - - - - - {workspace.name} - - - - {workspace.branch} - - - · {formatDistanceToNow(workspace.updatedAt, { addSuffix: true })} - + + + + - - - {pullRequest ? ( - - - +{pullRequest.additions} - {" "} + + + {workspace.name} + + - −{pullRequest.deletions} + {workspace.branch} - - ) : null} - {pullRequest && prBadge ? ( - Linking.openURL(pullRequest.url)} - className={`flex-row items-center gap-1 rounded-md px-2 py-1 ${prBadge.containerClassName}`} - > - - - #{pullRequest.prNumber} + + ·{" "} + {workspace.worktreeExists === false + ? "worktree missing" + : formatDistanceToNow(workspace.updatedAt, { + addSuffix: true, + })} - - ) : null} - - + + + + {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)/(tabs)/(home)/workspaces/components/WorkspaceRow/components/WorkspaceRowMenu/WorkspaceRowMenu.tsx b/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceRow/components/WorkspaceRowMenu/WorkspaceRowMenu.tsx new file mode 100644 index 00000000000..a729cf59ef2 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(tabs)/(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)/(tabs)/(home)/workspaces/components/WorkspaceRow/components/WorkspaceRowMenu/index.ts b/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceRow/components/WorkspaceRowMenu/index.ts new file mode 100644 index 00000000000..fadc4098642 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceRow/components/WorkspaceRowMenu/index.ts @@ -0,0 +1 @@ +export { WorkspaceRowMenu } from "./WorkspaceRowMenu"; 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 index 98c94bb73a2..d687f73251d 100644 --- a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceRow/index.ts +++ b/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceRow/index.ts @@ -1 +1 @@ -export { WorkspaceRow } from "./WorkspaceRow"; +export { prStateFor, WorkspaceRow } from "./WorkspaceRow"; diff --git a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/hooks/useVisibleDiffStats/index.ts b/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/hooks/useVisibleDiffStats/index.ts new file mode 100644 index 00000000000..029dbf0f7cd --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/hooks/useVisibleDiffStats/index.ts @@ -0,0 +1 @@ +export { type DiffStats, useVisibleDiffStats } from "./useVisibleDiffStats"; diff --git a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/hooks/useVisibleDiffStats/useVisibleDiffStats.ts b/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/hooks/useVisibleDiffStats/useVisibleDiffStats.ts new file mode 100644 index 00000000000..1a418a8863e --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/hooks/useVisibleDiffStats/useVisibleDiffStats.ts @@ -0,0 +1,87 @@ +import { useQueries } 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; + +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 for the workspaces currently settled in the + * viewport — bounded per screenful, not per project. + */ +export function useVisibleDiffStats({ + visibleIds, + workspacesById, + resolveHostUrl, +}: { + visibleIds: string[]; + workspacesById: Map; + resolveHostUrl: (hostId: string) => string | null; +}): Map { + const eligible = useMemo(() => { + const result: Array<{ workspaceId: string; hostUrl: string }> = []; + for (const workspaceId of visibleIds) { + const workspace = workspacesById.get(workspaceId); + if ( + !workspace || + workspace.source !== "host" || + !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, + retry: 1, + networkMode: "always" as const, + queryFn: () => + getHostServiceClientByUrl(hostUrl).git.getStatus.query({ + workspaceId, + priority: "background", + }), + })), + }); + + return useMemo(() => { + const map = new Map(); + eligible.forEach(({ workspaceId }, index) => { + const status = queries[index]?.data; + if (status) map.set(workspaceId, aggregateDiffStats(status)); + }); + return map; + }, [eligible, queries]); +} 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..b5c6fbbf891 100644 --- a/bun.lock +++ b/bun.lock @@ -497,6 +497,8 @@ "@shikijs/themes": "4.2.0", "@superset/auth": "workspace:*", "@superset/db": "workspace:*", + "@superset/host-service": "workspace:*", + "@superset/shared": "workspace:*", "@superset/trpc": "workspace:*", "@tanstack/db": "0.6.10", "@tanstack/electric-db-collection": "0.3.8", From 264995a4ed45bdc1407af531a25b1ab2ddb29e71 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Wed, 8 Jul 2026 14:21:44 -0700 Subject: [PATCH 02/30] feat(mobile): workspace filter sheet redesign, switcher settings section, row polish --- .../(home)/workspaces/WorkspacesScreen.tsx | 32 +- .../OrganizationSwitcherSheet.tsx | 59 +++ .../WorkspaceFilterSheet.tsx | 381 ++++++++++++------ .../components/WorkspaceRow/WorkspaceRow.tsx | 52 ++- .../WorkspaceRowMenu/WorkspaceRowMenu.tsx | 2 +- 5 files changed, 352 insertions(+), 174 deletions(-) diff --git a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/WorkspacesScreen.tsx b/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/WorkspacesScreen.tsx index 8f843e1db33..5a5f644c614 100644 --- a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/WorkspacesScreen.tsx +++ b/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/WorkspacesScreen.tsx @@ -5,12 +5,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { compareDesc, isAfter } from "date-fns"; import { Stack, useFocusEffect } from "expo-router"; import { useCallback, useMemo, useState } from "react"; -import { - Pressable, - RefreshControl, - useWindowDimensions, - View, -} from "react-native"; +import { RefreshControl, useWindowDimensions, View } from "react-native"; import { Text } from "@/components/ui/text"; import { type HostWorkspaceItem, @@ -20,7 +15,6 @@ import { useOrganizations } from "@/screens/(authenticated)/hooks/useOrganizatio import { useCollections } from "@/screens/(authenticated)/providers/CollectionsProvider"; import { OrganizationHeaderButton } from "./components/OrganizationHeaderButton"; import { OrganizationSwitcherSheet } from "./components/OrganizationSwitcherSheet"; -import { ProjectAvatar } from "./components/ProjectAvatar"; import { type FilterableHost, type FilterableProject, @@ -221,24 +215,15 @@ export function WorkspacesScreen() { onPress={() => setSheetOpen(true)} /> - - setFilterSheetOpen(true)} - style={{ height: 24, width: 24 }} - > - - - + setFilterSheetOpen(true)} + /> item.id} @@ -271,10 +256,7 @@ export function WorkspacesScreen() { onIsPresentedChange={setFilterSheetOpen} projects={filterableProjects} selectedProjectId={selectedProjectId} - onSelectProject={(projectId) => { - setProjectFilter(projectId); - setFilterSheetOpen(false); - }} + onSelectProject={setProjectFilter} hosts={filterableHosts} selectedHostId={hostFilter} onSelectHost={setHostFilter} diff --git a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/OrganizationSwitcherSheet/OrganizationSwitcherSheet.tsx b/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/OrganizationSwitcherSheet/OrganizationSwitcherSheet.tsx index 7bc7ee92c0f..89b9f9608d2 100644 --- a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/OrganizationSwitcherSheet/OrganizationSwitcherSheet.tsx +++ b/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/OrganizationSwitcherSheet/OrganizationSwitcherSheet.tsx @@ -7,7 +7,9 @@ import { import Ionicons from "@expo/vector-icons/Ionicons"; import { Pressable, View } from "react-native"; import { Text } from "@/components/ui/text"; +import { useSignOut } from "@/hooks/useSignOut"; import { useTheme } from "@/hooks/useTheme"; +import { useSession } from "@/lib/auth/client"; import { OrganizationAvatar } from "./components/OrganizationAvatar"; export interface Organization { @@ -33,6 +35,13 @@ export function OrganizationSwitcherSheet({ width: number; }) { const theme = useTheme(); + const { data: session } = useSession(); + const { signOut, isSigningOut } = useSignOut(); + + const handleSignOut = () => { + onIsPresentedChange(false); + void signOut(); + }; return ( @@ -97,6 +106,56 @@ export function OrganizationSwitcherSheet({ ); })} + + + Settings + + {session?.user ? ( + + + + + {session.user.name} + + + {session.user.email} + + + + ) : null} + + + + Log out + + 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 index 1c0029982c3..7ac62fd3fe3 100644 --- a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceFilterSheet/WorkspaceFilterSheet.tsx +++ b/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceFilterSheet/WorkspaceFilterSheet.tsx @@ -2,10 +2,14 @@ import { BottomSheet, Group, Host, RNHostView } from "@expo/ui/swift-ui"; import { background, environment, + frame, + presentationDetents, presentationDragIndicator, } from "@expo/ui/swift-ui/modifiers"; import Ionicons from "@expo/vector-icons/Ionicons"; +import { type ReactNode, useEffect, useState } from "react"; import { Pressable, ScrollView, View } from "react-native"; +import { Separator } from "@/components/ui/separator"; import { Text } from "@/components/ui/text"; import { useTheme } from "@/hooks/useTheme"; import { ProjectAvatar } from "../ProjectAvatar"; @@ -31,6 +35,84 @@ const SORT_OPTIONS: { value: WorkspaceSort; label: string }[] = [ { label: "Date created", value: "createdAt" }, ]; +const ONLINE_COLOR = "#3fb950"; + +type FilterView = "root" | "sort" | "project" | "host"; + +function HostStatusDot({ isOnline }: { isOnline: boolean }) { + const theme = useTheme(); + return ( + + ); +} + +function SheetHeader({ + title, + onBack, +}: { + title: string; + onBack?: () => void; +}) { + const theme = useTheme(); + return ( + + {onBack ? ( + + + + ) : null} + + {title} + + + ); +} + +function SheetRow({ + icon, + label, + trailing, + onPress, + isLast, +}: { + icon?: ReactNode; + label: string; + trailing: ReactNode; + onPress: () => void; + isLast?: boolean; +}) { + const theme = useTheme(); + return ( + <> + + {icon ? {icon} : null} + + {label} + + + {trailing} + + + {isLast ? null : } + + ); +} + +function CheckMark({ visible }: { visible: boolean }) { + const theme = useTheme(); + return visible ? ( + + ) : null; +} + export function WorkspaceFilterSheet({ isPresented, onIsPresentedChange, @@ -57,160 +139,191 @@ export function WorkspaceFilterSheet({ width: number; }) { const theme = useTheme(); + const [view, setView] = useState("root"); + + useEffect(() => { + if (isPresented) setView("root"); + }, [isPresented]); + + const selectedProject = projects.find( + (project) => project.id === selectedProjectId, + ); + const selectedHost = hosts.find((host) => host.machineId === selectedHostId); + const sortLabel = + SORT_OPTIONS.find((option) => option.value === sort)?.label ?? ""; + + const mutedIcon = (name: keyof typeof Ionicons.glyphMap) => ( + + ); + + const trailingValue = (value: string, accessory?: ReactNode) => ( + <> + {accessory} + + {value} + + + + ); 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 ? ( - + {view === "root" ? ( + <> + + - ) : null} - - ); - })} - - - Host - - onSelectHost(null)} - className="flex-row items-center gap-2.5 py-2.5" - > - - All hosts - - {selectedHostId === null ? ( - setView("project")} /> - ) : null} - - {hosts.map((host) => { - const isActive = host.machineId === selectedHostId; - return ( - onSelectHost(host.machineId)} - className="flex-row items-center gap-2.5 py-2.5" - > - + ) : undefined, + )} + onPress={() => setView("host")} + /> + setView("sort")} + isLast + /> + + ) : null} + {view === "sort" ? ( + <> + setView("root")} /> + {SORT_OPTIONS.map((option, index) => ( + } + onPress={() => { + onChangeSort(option.value); + setView("root"); + }} + isLast={index === SORT_OPTIONS.length - 1} + /> + ))} + + ) : null} + {view === "project" ? ( + <> + setView("root")} /> + + {projects.map((project, index) => ( + + } + label={project.name} + trailing={ + <> + + {project.workspaceCount} + + + + } + onPress={() => { + onSelectProject(project.id); + setView("root"); + }} + isLast={index === projects.length - 1} + /> + ))} + + + ) : null} + {view === "host" ? ( + <> + setView("root")} /> + + } + onPress={() => { + onSelectHost(null); + setView("root"); }} /> - - {host.name} - - - {host.workspaceCount} - - {isActive ? ( - ( + } + label={host.name} + trailing={ + <> + + {host.workspaceCount} + + + + } + onPress={() => { + onSelectHost(host.machineId); + setView("root"); + }} + isLast={index === hosts.length - 1} /> - ) : null} - - ); - })} + ))} + + + ) : null} 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 index 192a6aa932c..55cc93923f0 100644 --- a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceRow/WorkspaceRow.tsx +++ b/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceRow/WorkspaceRow.tsx @@ -1,20 +1,25 @@ import type { SelectGithubPullRequest } 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 { 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"; @@ -66,22 +71,41 @@ export function WorkspaceRow({ }) { const prBadge = pullRequest ? PR_BADGE_CONFIG[prStateFor(pullRequest)] : null; - const HostIcon = - workspace.source === "cloud" - ? Circle - : workspace.hostReachable - ? Cloud - : CloudOff; + 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.source === "cloud" ? ( + + ) : ( + + )} diff --git a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceRow/components/WorkspaceRowMenu/WorkspaceRowMenu.tsx b/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceRow/components/WorkspaceRowMenu/WorkspaceRowMenu.tsx index a729cf59ef2..090b3a49fd0 100644 --- a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceRow/components/WorkspaceRowMenu/WorkspaceRowMenu.tsx +++ b/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceRow/components/WorkspaceRowMenu/WorkspaceRowMenu.tsx @@ -112,7 +112,7 @@ export function WorkspaceRowMenu({ Rename {workspace.type !== "main" ? ( - + Delete ) : null} From 05b78c19a8505be0072717539fba4e7d0328ce0a Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Wed, 8 Jul 2026 14:23:09 -0700 Subject: [PATCH 03/30] feat(mobile): reserve bottom padding on workspace list for docked input --- .../(tabs)/(home)/workspaces/WorkspacesScreen.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/WorkspacesScreen.tsx b/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/WorkspacesScreen.tsx index 5a5f644c614..d353fd70221 100644 --- a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/WorkspacesScreen.tsx +++ b/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/WorkspacesScreen.tsx @@ -222,8 +222,11 @@ export function WorkspacesScreen() { item.id} From 68ae97a8127d911f9d2a8b5e7aa2ba77c59f3d36 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Wed, 8 Jul 2026 14:28:00 -0700 Subject: [PATCH 04/30] feat(mobile): drop tab bar for single-stack nav; integrated header search; settings via org switcher --- .../{(tabs) => }/(home)/_layout.tsx | 0 .../app/(authenticated)/(home)/index.tsx | 3 + .../(authenticated)/(tabs)/(home)/index.tsx | 3 - .../(authenticated)/(tabs)/(more)/_layout.tsx | 11 -- .../(authenticated)/(tabs)/(more)/index.tsx | 3 - .../(tabs)/(more)/settings/index.tsx | 3 - .../(tabs)/(search)/_layout.tsx | 10 -- .../(authenticated)/(tabs)/(search)/index.tsx | 3 - .../(authenticated)/(tabs)/(tasks)/[id].tsx | 3 - .../(tabs)/(tasks)/_layout.tsx | 11 -- .../(authenticated)/(tabs)/(tasks)/index.tsx | 3 - .../app/(authenticated)/(tabs)/_layout.tsx | 34 ----- apps/mobile/app/(authenticated)/_layout.tsx | 3 +- .../app/(authenticated)/settings/index.tsx | 3 + .../(home)/workspaces/WorkspacesScreen.tsx | 100 +++++++++---- .../OrganizationHeaderButton.tsx | 0 .../OrganizationHeaderButton/index.ts | 0 .../OrganizationSwitcherSheet.tsx | 23 +++ .../OrganizationAvatar/OrganizationAvatar.tsx | 0 .../components/OrganizationAvatar/index.ts | 0 .../OrganizationSwitcherSheet/index.ts | 0 .../ProjectAvatar/ProjectAvatar.tsx | 0 .../components/ProjectAvatar/index.ts | 0 .../WorkspaceFilterSheet.tsx | 35 +++-- .../components/WorkspaceFilterSheet/index.ts | 0 .../components/WorkspaceRow/WorkspaceRow.tsx | 0 .../WorkspaceRowMenu/WorkspaceRowMenu.tsx | 0 .../components/WorkspaceRowMenu/index.ts | 0 .../components/WorkspaceRow/index.ts | 0 .../hooks/useVisibleDiffStats/index.ts | 0 .../useVisibleDiffStats.ts | 0 .../{(tabs) => }/(home)/workspaces/index.ts | 0 .../(tabs)/(more)/MoreMenuScreen.tsx | 139 ------------------ .../(authenticated)/(tabs)/(more)/index.ts | 1 - .../(tabs)/(search)/SearchScreen.tsx | 97 ------------ .../(authenticated)/(tabs)/(search)/index.ts | 1 - .../(tabs)/(tasks)/[id]/TaskDetailScreen.tsx | 36 ----- .../(tabs)/(tasks)/[id]/index.ts | 1 - .../(tabs)/(tasks)/tasks/TasksScreen.tsx | 30 ---- .../(tabs)/(tasks)/tasks/index.ts | 1 - .../components/OrgDropdown/OrgDropdown.tsx | 107 -------------- .../components/OrgDropdown/index.ts | 1 - .../TabBarAccessory/TabBarAccessory.tsx | 70 --------- .../components/TabBarAccessory/index.ts | 1 - .../useOrganizations/useOrganizations.ts | 2 +- .../(more) => }/settings/SettingsScreen.tsx | 0 .../{(tabs)/(more) => }/settings/index.ts | 0 .../WorkspaceBackButton.tsx | 2 +- 48 files changed, 126 insertions(+), 614 deletions(-) rename apps/mobile/app/(authenticated)/{(tabs) => }/(home)/_layout.tsx (100%) create mode 100644 apps/mobile/app/(authenticated)/(home)/index.tsx delete mode 100644 apps/mobile/app/(authenticated)/(tabs)/(home)/index.tsx delete mode 100644 apps/mobile/app/(authenticated)/(tabs)/(more)/_layout.tsx delete mode 100644 apps/mobile/app/(authenticated)/(tabs)/(more)/index.tsx delete mode 100644 apps/mobile/app/(authenticated)/(tabs)/(more)/settings/index.tsx delete mode 100644 apps/mobile/app/(authenticated)/(tabs)/(search)/_layout.tsx delete mode 100644 apps/mobile/app/(authenticated)/(tabs)/(search)/index.tsx delete mode 100644 apps/mobile/app/(authenticated)/(tabs)/(tasks)/[id].tsx delete mode 100644 apps/mobile/app/(authenticated)/(tabs)/(tasks)/_layout.tsx delete mode 100644 apps/mobile/app/(authenticated)/(tabs)/(tasks)/index.tsx delete mode 100644 apps/mobile/app/(authenticated)/(tabs)/_layout.tsx create mode 100644 apps/mobile/app/(authenticated)/settings/index.tsx rename apps/mobile/screens/(authenticated)/{(tabs) => }/(home)/workspaces/WorkspacesScreen.tsx (78%) rename apps/mobile/screens/(authenticated)/{(tabs) => }/(home)/workspaces/components/OrganizationHeaderButton/OrganizationHeaderButton.tsx (100%) rename apps/mobile/screens/(authenticated)/{(tabs) => }/(home)/workspaces/components/OrganizationHeaderButton/index.ts (100%) rename apps/mobile/screens/(authenticated)/{(tabs) => }/(home)/workspaces/components/OrganizationSwitcherSheet/OrganizationSwitcherSheet.tsx (88%) rename apps/mobile/screens/(authenticated)/{(tabs) => }/(home)/workspaces/components/OrganizationSwitcherSheet/components/OrganizationAvatar/OrganizationAvatar.tsx (100%) rename apps/mobile/screens/(authenticated)/{(tabs) => }/(home)/workspaces/components/OrganizationSwitcherSheet/components/OrganizationAvatar/index.ts (100%) rename apps/mobile/screens/(authenticated)/{(tabs) => }/(home)/workspaces/components/OrganizationSwitcherSheet/index.ts (100%) rename apps/mobile/screens/(authenticated)/{(tabs) => }/(home)/workspaces/components/ProjectAvatar/ProjectAvatar.tsx (100%) rename apps/mobile/screens/(authenticated)/{(tabs) => }/(home)/workspaces/components/ProjectAvatar/index.ts (100%) rename apps/mobile/screens/(authenticated)/{(tabs) => }/(home)/workspaces/components/WorkspaceFilterSheet/WorkspaceFilterSheet.tsx (93%) rename apps/mobile/screens/(authenticated)/{(tabs) => }/(home)/workspaces/components/WorkspaceFilterSheet/index.ts (100%) rename apps/mobile/screens/(authenticated)/{(tabs) => }/(home)/workspaces/components/WorkspaceRow/WorkspaceRow.tsx (100%) rename apps/mobile/screens/(authenticated)/{(tabs) => }/(home)/workspaces/components/WorkspaceRow/components/WorkspaceRowMenu/WorkspaceRowMenu.tsx (100%) rename apps/mobile/screens/(authenticated)/{(tabs) => }/(home)/workspaces/components/WorkspaceRow/components/WorkspaceRowMenu/index.ts (100%) rename apps/mobile/screens/(authenticated)/{(tabs) => }/(home)/workspaces/components/WorkspaceRow/index.ts (100%) rename apps/mobile/screens/(authenticated)/{(tabs) => }/(home)/workspaces/hooks/useVisibleDiffStats/index.ts (100%) rename apps/mobile/screens/(authenticated)/{(tabs) => }/(home)/workspaces/hooks/useVisibleDiffStats/useVisibleDiffStats.ts (100%) rename apps/mobile/screens/(authenticated)/{(tabs) => }/(home)/workspaces/index.ts (100%) delete mode 100644 apps/mobile/screens/(authenticated)/(tabs)/(more)/MoreMenuScreen.tsx delete mode 100644 apps/mobile/screens/(authenticated)/(tabs)/(more)/index.ts delete mode 100644 apps/mobile/screens/(authenticated)/(tabs)/(search)/SearchScreen.tsx delete mode 100644 apps/mobile/screens/(authenticated)/(tabs)/(search)/index.ts delete mode 100644 apps/mobile/screens/(authenticated)/(tabs)/(tasks)/[id]/TaskDetailScreen.tsx delete mode 100644 apps/mobile/screens/(authenticated)/(tabs)/(tasks)/[id]/index.ts delete mode 100644 apps/mobile/screens/(authenticated)/(tabs)/(tasks)/tasks/TasksScreen.tsx delete mode 100644 apps/mobile/screens/(authenticated)/(tabs)/(tasks)/tasks/index.ts delete mode 100644 apps/mobile/screens/(authenticated)/components/OrgDropdown/OrgDropdown.tsx delete mode 100644 apps/mobile/screens/(authenticated)/components/OrgDropdown/index.ts delete mode 100644 apps/mobile/screens/(authenticated)/components/TabBarAccessory/TabBarAccessory.tsx delete mode 100644 apps/mobile/screens/(authenticated)/components/TabBarAccessory/index.ts rename apps/mobile/screens/(authenticated)/{(tabs)/(more) => }/settings/SettingsScreen.tsx (100%) rename apps/mobile/screens/(authenticated)/{(tabs)/(more) => }/settings/index.ts (100%) diff --git a/apps/mobile/app/(authenticated)/(tabs)/(home)/_layout.tsx b/apps/mobile/app/(authenticated)/(home)/_layout.tsx similarity index 100% rename from apps/mobile/app/(authenticated)/(tabs)/(home)/_layout.tsx rename to apps/mobile/app/(authenticated)/(home)/_layout.tsx 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)/(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 03d10262aba..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 ( - - - - - - - - - - - - - - - - - - - ); -} diff --git a/apps/mobile/app/(authenticated)/_layout.tsx b/apps/mobile/app/(authenticated)/_layout.tsx index f9f89c40516..f2927a2f897 100644 --- a/apps/mobile/app/(authenticated)/_layout.tsx +++ b/apps/mobile/app/(authenticated)/_layout.tsx @@ -8,8 +8,9 @@ export default function AuthenticatedLayout() { return ( - + + ); 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/screens/(authenticated)/(tabs)/(home)/workspaces/WorkspacesScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/WorkspacesScreen.tsx similarity index 78% rename from apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/WorkspacesScreen.tsx rename to apps/mobile/screens/(authenticated)/(home)/workspaces/WorkspacesScreen.tsx index d353fd70221..99c705a265a 100644 --- a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/WorkspacesScreen.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/WorkspacesScreen.tsx @@ -11,6 +11,7 @@ import { type HostWorkspaceItem, useHostWorkspaces, } from "@/hooks/useHostWorkspaces"; +import { THEME } from "@/lib/theme"; import { useOrganizations } from "@/screens/(authenticated)/hooks/useOrganizations"; import { useCollections } from "@/screens/(authenticated)/providers/CollectionsProvider"; import { OrganizationHeaderButton } from "./components/OrganizationHeaderButton"; @@ -37,6 +38,7 @@ export function WorkspacesScreen() { const [projectFilter, setProjectFilter] = useState(null); const [hostFilter, setHostFilter] = useState(null); const [sort, setSort] = useState("updatedAt"); + const [searchQuery, setSearchQuery] = useState(""); const [visibleIds, setVisibleIds] = useState([]); const [refreshing, setRefreshing] = useState(false); const { width } = useWindowDimensions(); @@ -86,9 +88,6 @@ export function WorkspacesScreen() { }, [sortedProjects, workspaces]); const selectedProjectId = projectFilter ?? sortedProjects[0]?.id ?? null; - const selectedProject = sortedProjects.find( - (project) => project.id === selectedProjectId, - ); const filterableHosts = useMemo(() => { const counts = new Map(); @@ -106,15 +105,37 @@ export function WorkspacesScreen() { .sort((a, b) => a.name.localeCompare(b.name)); }, [hosts, workspaces, selectedProjectId]); + const projectNamesById = useMemo( + () => + new Map((projects ?? []).map((project) => [project.id, project.name])), + [projects], + ); + const visibleWorkspaces = useMemo(() => { - return workspaces - .filter( - (workspace) => - workspace.projectId === selectedProjectId && - (hostFilter === null || workspace.hostId === hostFilter), - ) - .sort((a, b) => compareDesc(a[sort], b[sort])); - }, [workspaces, selectedProjectId, hostFilter, sort]); + 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 && + (hostFilter === null || workspace.hostId === hostFilter), + ); + return matches.sort((a, b) => compareDesc(a[sort], b[sort])); + }, [ + workspaces, + selectedProjectId, + hostFilter, + sort, + searchQuery, + projectNamesById, + ]); const workspacesById = useMemo( () => new Map(workspaces.map((workspace) => [workspace.id, workspace])), @@ -182,24 +203,34 @@ export function WorkspacesScreen() { setRefreshing(false); }, [queryClient]); - const projectRepositoryId = selectedProject?.githubRepositoryId ?? null; + const repositoryIdsByProject = useMemo( + () => + new Map( + (projects ?? []).map((project) => [ + project.id, + project.githubRepositoryId, + ]), + ), + [projects], + ); const renderItem = useCallback( - ({ item }: { item: HostWorkspaceItem }) => ( - - ), - [pullRequestsByRepoBranch, projectRepositoryId, diffStats, cache], + ({ item }: { item: HostWorkspaceItem }) => { + const repositoryId = repositoryIdsByProject.get(item.projectId); + return ( + + ); + }, + [pullRequestsByRepoBranch, repositoryIdsByProject, diffStats, cache], ); const handleSwitchOrganization = (organizationId: string) => { @@ -220,6 +251,17 @@ export function WorkspacesScreen() { onPress={() => setFilterSheetOpen(true)} /> + setSearchQuery(event.nativeEvent.text)} + onCancelButtonPress={() => setSearchQuery("")} + /> - No workspaces in this project yet + {searchQuery.trim() + ? "No workspaces match your search" + : "No workspaces in this project yet"} ) : null 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 100% 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 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 88% 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 89b9f9608d2..93e1e7fc1c5 100644 --- a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/OrganizationSwitcherSheet/OrganizationSwitcherSheet.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationSwitcherSheet/OrganizationSwitcherSheet.tsx @@ -5,6 +5,7 @@ import { 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"; @@ -35,6 +36,7 @@ export function OrganizationSwitcherSheet({ width: number; }) { const theme = useTheme(); + const router = useRouter(); const { data: session } = useSession(); const { signOut, isSigningOut } = useSignOut(); @@ -43,6 +45,11 @@ export function OrganizationSwitcherSheet({ void signOut(); }; + const handleOpenSettings = () => { + onIsPresentedChange(false); + router.push("/(authenticated)/settings"); + }; + return ( ) : null} + + + + Settings + + - - {icon ? {icon} : null} - - {label} - - - {trailing} - - - {isLast ? null : } - + + {icon ? {icon} : null} + + {label} + + + {trailing} + + ); } diff --git a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceFilterSheet/index.ts b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/WorkspaceFilterSheet/index.ts similarity index 100% rename from apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceFilterSheet/index.ts rename to apps/mobile/screens/(authenticated)/(home)/workspaces/components/WorkspaceFilterSheet/index.ts diff --git a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceRow/WorkspaceRow.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/WorkspaceRow/WorkspaceRow.tsx similarity index 100% rename from apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceRow/WorkspaceRow.tsx rename to apps/mobile/screens/(authenticated)/(home)/workspaces/components/WorkspaceRow/WorkspaceRow.tsx diff --git a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceRow/components/WorkspaceRowMenu/WorkspaceRowMenu.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/WorkspaceRow/components/WorkspaceRowMenu/WorkspaceRowMenu.tsx similarity index 100% rename from apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceRow/components/WorkspaceRowMenu/WorkspaceRowMenu.tsx rename to apps/mobile/screens/(authenticated)/(home)/workspaces/components/WorkspaceRow/components/WorkspaceRowMenu/WorkspaceRowMenu.tsx diff --git a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceRow/components/WorkspaceRowMenu/index.ts b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/WorkspaceRow/components/WorkspaceRowMenu/index.ts similarity index 100% rename from apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceRow/components/WorkspaceRowMenu/index.ts rename to apps/mobile/screens/(authenticated)/(home)/workspaces/components/WorkspaceRow/components/WorkspaceRowMenu/index.ts diff --git a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceRow/index.ts b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/WorkspaceRow/index.ts similarity index 100% rename from apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/components/WorkspaceRow/index.ts rename to apps/mobile/screens/(authenticated)/(home)/workspaces/components/WorkspaceRow/index.ts diff --git a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/hooks/useVisibleDiffStats/index.ts b/apps/mobile/screens/(authenticated)/(home)/workspaces/hooks/useVisibleDiffStats/index.ts similarity index 100% rename from apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/hooks/useVisibleDiffStats/index.ts rename to apps/mobile/screens/(authenticated)/(home)/workspaces/hooks/useVisibleDiffStats/index.ts diff --git a/apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/hooks/useVisibleDiffStats/useVisibleDiffStats.ts b/apps/mobile/screens/(authenticated)/(home)/workspaces/hooks/useVisibleDiffStats/useVisibleDiffStats.ts similarity index 100% rename from apps/mobile/screens/(authenticated)/(tabs)/(home)/workspaces/hooks/useVisibleDiffStats/useVisibleDiffStats.ts rename to apps/mobile/screens/(authenticated)/(home)/workspaces/hooks/useVisibleDiffStats/useVisibleDiffStats.ts 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)/(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)/(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/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)/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)/(tabs)/(more)/settings/SettingsScreen.tsx b/apps/mobile/screens/(authenticated)/settings/SettingsScreen.tsx similarity index 100% rename from apps/mobile/screens/(authenticated)/(tabs)/(more)/settings/SettingsScreen.tsx rename to apps/mobile/screens/(authenticated)/settings/SettingsScreen.tsx 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)/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 ( From 6d1657ff5a6e21bdc5e7b16d1a43a391333fe8e7 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Wed, 8 Jul 2026 14:44:57 -0700 Subject: [PATCH 05/30] feat(mobile): move workspace filters to formSheet route with zustand store; fix list header inset overlap --- .../app/(authenticated)/(home)/_layout.tsx | 9 + .../(authenticated)/(home)/filter/_layout.tsx | 17 + .../(authenticated)/(home)/filter/host.tsx | 3 + .../(authenticated)/(home)/filter/index.tsx | 3 + .../(authenticated)/(home)/filter/project.tsx | 3 + .../(authenticated)/(home)/filter/sort.tsx | 3 + apps/mobile/package.json | 3 +- .../(home)/filter/FilterScreen.tsx | 110 ++++++ .../components/FilterCheck/FilterCheck.tsx | 9 + .../filter/components/FilterCheck/index.ts | 1 + .../filter/components/FilterRow/FilterRow.tsx | 42 +++ .../filter/components/FilterRow/index.ts | 1 + .../FilterRowValue/FilterRowValue.tsx | 31 ++ .../filter/components/FilterRowValue/index.ts | 1 + .../HostStatusDot/HostStatusDot.tsx | 16 + .../filter/components/HostStatusDot/index.ts | 1 + .../ProjectAvatar/ProjectAvatar.tsx | 0 .../components/ProjectAvatar/index.ts | 0 .../(home)/filter/host/HostFilterScreen.tsx | 96 +++++ .../(home)/filter/host/index.ts | 1 + .../(authenticated)/(home)/filter/index.ts | 1 + .../filter/project/ProjectFilterScreen.tsx | 84 +++++ .../(home)/filter/project/index.ts | 1 + .../(home)/filter/sort/SortFilterScreen.tsx | 31 ++ .../(home)/filter/sort/index.ts | 1 + .../(home)/workspaces/WorkspacesScreen.tsx | 94 ++--- .../WorkspaceFilterSheet.tsx | 340 ------------------ .../components/WorkspaceFilterSheet/index.ts | 6 - .../stores/workspacesFilterStore/index.ts | 5 + .../workspacesFilterStore.ts | 28 ++ bun.lock | 2 +- 31 files changed, 525 insertions(+), 418 deletions(-) create mode 100644 apps/mobile/app/(authenticated)/(home)/filter/_layout.tsx create mode 100644 apps/mobile/app/(authenticated)/(home)/filter/host.tsx create mode 100644 apps/mobile/app/(authenticated)/(home)/filter/index.tsx create mode 100644 apps/mobile/app/(authenticated)/(home)/filter/project.tsx create mode 100644 apps/mobile/app/(authenticated)/(home)/filter/sort.tsx create mode 100644 apps/mobile/screens/(authenticated)/(home)/filter/FilterScreen.tsx create mode 100644 apps/mobile/screens/(authenticated)/(home)/filter/components/FilterCheck/FilterCheck.tsx create mode 100644 apps/mobile/screens/(authenticated)/(home)/filter/components/FilterCheck/index.ts create mode 100644 apps/mobile/screens/(authenticated)/(home)/filter/components/FilterRow/FilterRow.tsx create mode 100644 apps/mobile/screens/(authenticated)/(home)/filter/components/FilterRow/index.ts create mode 100644 apps/mobile/screens/(authenticated)/(home)/filter/components/FilterRowValue/FilterRowValue.tsx create mode 100644 apps/mobile/screens/(authenticated)/(home)/filter/components/FilterRowValue/index.ts create mode 100644 apps/mobile/screens/(authenticated)/(home)/filter/components/HostStatusDot/HostStatusDot.tsx create mode 100644 apps/mobile/screens/(authenticated)/(home)/filter/components/HostStatusDot/index.ts rename apps/mobile/screens/(authenticated)/(home)/{workspaces => filter}/components/ProjectAvatar/ProjectAvatar.tsx (100%) rename apps/mobile/screens/(authenticated)/(home)/{workspaces => filter}/components/ProjectAvatar/index.ts (100%) create mode 100644 apps/mobile/screens/(authenticated)/(home)/filter/host/HostFilterScreen.tsx create mode 100644 apps/mobile/screens/(authenticated)/(home)/filter/host/index.ts create mode 100644 apps/mobile/screens/(authenticated)/(home)/filter/index.ts create mode 100644 apps/mobile/screens/(authenticated)/(home)/filter/project/ProjectFilterScreen.tsx create mode 100644 apps/mobile/screens/(authenticated)/(home)/filter/project/index.ts create mode 100644 apps/mobile/screens/(authenticated)/(home)/filter/sort/SortFilterScreen.tsx create mode 100644 apps/mobile/screens/(authenticated)/(home)/filter/sort/index.ts delete mode 100644 apps/mobile/screens/(authenticated)/(home)/workspaces/components/WorkspaceFilterSheet/WorkspaceFilterSheet.tsx delete mode 100644 apps/mobile/screens/(authenticated)/(home)/workspaces/components/WorkspaceFilterSheet/index.ts create mode 100644 apps/mobile/screens/(authenticated)/(home)/workspaces/stores/workspacesFilterStore/index.ts create mode 100644 apps/mobile/screens/(authenticated)/(home)/workspaces/stores/workspacesFilterStore/workspacesFilterStore.ts diff --git a/apps/mobile/app/(authenticated)/(home)/_layout.tsx b/apps/mobile/app/(authenticated)/(home)/_layout.tsx index 6fade6e5bb5..804ef9bce8b 100644 --- a/apps/mobile/app/(authenticated)/(home)/_layout.tsx +++ b/apps/mobile/app/(authenticated)/(home)/_layout.tsx @@ -9,6 +9,15 @@ export default function HomeLayout() { }} > + ); } 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/package.json b/apps/mobile/package.json index d0cb65ca048..25e0153822d 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -107,7 +107,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", 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..3fa60dbf818 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/filter/FilterScreen.tsx @@ -0,0 +1,110 @@ +import Ionicons from "@expo/vector-icons/Ionicons"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useRouter } from "expo-router"; +import { View } from "react-native"; +import { useTheme } from "@/hooks/useTheme"; +import { + SORT_OPTIONS, + useWorkspacesFilterStore, +} from "@/screens/(authenticated)/(home)/workspaces/stores/workspacesFilterStore"; +import { useCollections } from "@/screens/(authenticated)/providers/CollectionsProvider"; +import { FilterRow } from "./components/FilterRow"; +import { FilterRowValue } from "./components/FilterRowValue"; +import { HostStatusDot } from "./components/HostStatusDot"; +import { ProjectAvatar } from "./components/ProjectAvatar"; + +export function FilterScreen() { + const router = useRouter(); + const theme = useTheme(); + const collections = useCollections(); + const projectFilter = useWorkspacesFilterStore( + (store) => store.projectFilter, + ); + const hostFilter = useWorkspacesFilterStore((store) => store.hostFilter); + const sort = useWorkspacesFilterStore((store) => store.sort); + + const { data: projects } = useLiveQuery( + (q) => q.from({ v2Projects: collections.v2Projects }), + [collections], + ); + const { data: hosts } = useLiveQuery( + (q) => q.from({ v2Hosts: collections.v2Hosts }), + [collections], + ); + + const sortedProjects = [...(projects ?? [])].sort((a, b) => + a.name.localeCompare(b.name), + ); + const selectedProject = + sortedProjects.find((project) => project.id === projectFilter) ?? + sortedProjects[0]; + const selectedHost = (hosts ?? []).find( + (host) => host.machineId === hostFilter, + ); + const sortLabel = + SORT_OPTIONS.find((option) => option.value === sort)?.label ?? ""; + + return ( + + + } + 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)/(home)/filter/components/FilterCheck/FilterCheck.tsx b/apps/mobile/screens/(authenticated)/(home)/filter/components/FilterCheck/FilterCheck.tsx new file mode 100644 index 00000000000..3ba27de86c6 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/filter/components/FilterCheck/FilterCheck.tsx @@ -0,0 +1,9 @@ +import Ionicons from "@expo/vector-icons/Ionicons"; +import { useTheme } from "@/hooks/useTheme"; + +export function FilterCheck({ visible }: { visible: boolean }) { + const theme = useTheme(); + return visible ? ( + + ) : null; +} diff --git a/apps/mobile/screens/(authenticated)/(home)/filter/components/FilterCheck/index.ts b/apps/mobile/screens/(authenticated)/(home)/filter/components/FilterCheck/index.ts new file mode 100644 index 00000000000..41014e17749 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/filter/components/FilterCheck/index.ts @@ -0,0 +1 @@ +export { FilterCheck } from "./FilterCheck"; diff --git a/apps/mobile/screens/(authenticated)/(home)/filter/components/FilterRow/FilterRow.tsx b/apps/mobile/screens/(authenticated)/(home)/filter/components/FilterRow/FilterRow.tsx new file mode 100644 index 00000000000..3a2efc323b6 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/filter/components/FilterRow/FilterRow.tsx @@ -0,0 +1,42 @@ +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 FilterRow({ + icon, + label, + trailing, + onPress, + isLast, +}: { + icon?: ReactNode; + label: string; + trailing?: ReactNode; + onPress: () => void; + isLast?: boolean; +}) { + const theme = useTheme(); + return ( + + {icon ? {icon} : null} + + {label} + + + {trailing} + + + ); +} diff --git a/apps/mobile/screens/(authenticated)/(home)/filter/components/FilterRow/index.ts b/apps/mobile/screens/(authenticated)/(home)/filter/components/FilterRow/index.ts new file mode 100644 index 00000000000..1eff2cc8e33 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/filter/components/FilterRow/index.ts @@ -0,0 +1 @@ +export { FilterRow } from "./FilterRow"; diff --git a/apps/mobile/screens/(authenticated)/(home)/filter/components/FilterRowValue/FilterRowValue.tsx b/apps/mobile/screens/(authenticated)/(home)/filter/components/FilterRowValue/FilterRowValue.tsx new file mode 100644 index 00000000000..39ec2dbb356 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/filter/components/FilterRowValue/FilterRowValue.tsx @@ -0,0 +1,31 @@ +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 FilterRowValue({ + value, + accessory, +}: { + value: string; + accessory?: ReactNode; +}) { + const theme = useTheme(); + return ( + <> + {accessory} + + {value} + + + + ); +} diff --git a/apps/mobile/screens/(authenticated)/(home)/filter/components/FilterRowValue/index.ts b/apps/mobile/screens/(authenticated)/(home)/filter/components/FilterRowValue/index.ts new file mode 100644 index 00000000000..23a98cfbefb --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/filter/components/FilterRowValue/index.ts @@ -0,0 +1 @@ +export { FilterRowValue } from "./FilterRowValue"; diff --git a/apps/mobile/screens/(authenticated)/(home)/filter/components/HostStatusDot/HostStatusDot.tsx b/apps/mobile/screens/(authenticated)/(home)/filter/components/HostStatusDot/HostStatusDot.tsx new file mode 100644 index 00000000000..73508299860 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/filter/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)/(home)/filter/components/HostStatusDot/index.ts b/apps/mobile/screens/(authenticated)/(home)/filter/components/HostStatusDot/index.ts new file mode 100644 index 00000000000..02323bdb9c7 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/filter/components/HostStatusDot/index.ts @@ -0,0 +1 @@ +export { HostStatusDot } from "./HostStatusDot"; diff --git a/apps/mobile/screens/(authenticated)/(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)/(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)/(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)/(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..d1ed0ed2c76 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/filter/host/HostFilterScreen.tsx @@ -0,0 +1,96 @@ +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 { useWorkspacesFilterStore } from "@/screens/(authenticated)/(home)/workspaces/stores/workspacesFilterStore"; +import { useCollections } from "@/screens/(authenticated)/providers/CollectionsProvider"; +import { FilterCheck } from "../components/FilterCheck"; +import { FilterRow } from "../components/FilterRow"; +import { HostStatusDot } from "../components/HostStatusDot"; + +export function HostFilterScreen() { + const router = useRouter(); + const theme = useTheme(); + const collections = useCollections(); + const { workspaces } = useHostWorkspaces(); + const hostFilter = useWorkspacesFilterStore((store) => store.hostFilter); + const setHostFilter = useWorkspacesFilterStore( + (store) => store.setHostFilter, + ); + const projectFilter = useWorkspacesFilterStore( + (store) => store.projectFilter, + ); + + const { data: hosts } = useLiveQuery( + (q) => q.from({ v2Hosts: collections.v2Hosts }), + [collections], + ); + const { data: projects } = useLiveQuery( + (q) => q.from({ v2Projects: collections.v2Projects }), + [collections], + ); + + const selectedProjectId = useMemo(() => { + if (projectFilter) return projectFilter; + const sorted = [...(projects ?? [])].sort((a, b) => + a.name.localeCompare(b.name), + ); + return sorted[0]?.id ?? null; + }, [projects, projectFilter]); + + const sortedHosts = useMemo(() => { + const counts = new Map(); + for (const workspace of workspaces) { + if (workspace.projectId !== selectedProjectId) continue; + counts.set(workspace.hostId, (counts.get(workspace.hostId) ?? 0) + 1); + } + return (hosts ?? []) + .map((host) => ({ + machineId: host.machineId, + name: host.name, + isOnline: host.isOnline, + workspaceCount: counts.get(host.machineId) ?? 0, + })) + .sort((a, b) => a.name.localeCompare(b.name)); + }, [hosts, workspaces, selectedProjectId]); + + const selectHost = (machineId: string | null) => { + setHostFilter(machineId); + router.back(); + }; + + return ( + + } + onPress={() => selectHost(null)} + /> + {sortedHosts.map((host, index) => ( + } + label={host.name} + trailing={ + <> + + {host.workspaceCount} + + + + } + 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..1d01697972d --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/filter/project/ProjectFilterScreen.tsx @@ -0,0 +1,84 @@ +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 { useWorkspacesFilterStore } from "@/screens/(authenticated)/(home)/workspaces/stores/workspacesFilterStore"; +import { useCollections } from "@/screens/(authenticated)/providers/CollectionsProvider"; +import { FilterCheck } from "../components/FilterCheck"; +import { FilterRow } from "../components/FilterRow"; +import { ProjectAvatar } from "../components/ProjectAvatar"; + +export function ProjectFilterScreen() { + const router = useRouter(); + const theme = useTheme(); + const collections = useCollections(); + const { workspaces } = useHostWorkspaces(); + 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..e72861ba15c --- /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 { FilterCheck } from "../components/FilterCheck"; +import { FilterRow } from "../components/FilterRow"; + +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)/workspaces/WorkspacesScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/WorkspacesScreen.tsx index 99c705a265a..4af492765b5 100644 --- a/apps/mobile/screens/(authenticated)/(home)/workspaces/WorkspacesScreen.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/WorkspacesScreen.tsx @@ -3,9 +3,10 @@ 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 } from "expo-router"; +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, @@ -16,14 +17,9 @@ import { useOrganizations } from "@/screens/(authenticated)/hooks/useOrganizatio import { useCollections } from "@/screens/(authenticated)/providers/CollectionsProvider"; import { OrganizationHeaderButton } from "./components/OrganizationHeaderButton"; import { OrganizationSwitcherSheet } from "./components/OrganizationSwitcherSheet"; -import { - type FilterableHost, - type FilterableProject, - WorkspaceFilterSheet, - type WorkspaceSort, -} from "./components/WorkspaceFilterSheet"; import { prStateFor, WorkspaceRow } from "./components/WorkspaceRow"; import { useVisibleDiffStats } from "./hooks/useVisibleDiffStats"; +import { useWorkspacesFilterStore } from "./stores/workspacesFilterStore"; const VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 50, @@ -32,16 +28,21 @@ const VIEWABILITY_CONFIG = { const MAX_VISIBLE_DIFF_STATS = 20; +const NAVIGATION_BAR_HEIGHT = 44; + export function WorkspacesScreen() { + const router = useRouter(); const [sheetOpen, setSheetOpen] = useState(false); - const [filterSheetOpen, setFilterSheetOpen] = useState(false); - const [projectFilter, setProjectFilter] = useState(null); - const [hostFilter, setHostFilter] = useState(null); - const [sort, setSort] = useState("updatedAt"); + const projectFilter = useWorkspacesFilterStore( + (store) => store.projectFilter, + ); + const hostFilter = useWorkspacesFilterStore((store) => store.hostFilter); + const sort = useWorkspacesFilterStore((store) => store.sort); const [searchQuery, setSearchQuery] = useState(""); const [visibleIds, setVisibleIds] = useState([]); const [refreshing, setRefreshing] = useState(false); - const { width } = useWindowDimensions(); + const { width, height: windowHeight } = useWindowDimensions(); + const insets = useSafeAreaInsets(); const collections = useCollections(); const queryClient = useQueryClient(); const { @@ -61,50 +62,14 @@ export function WorkspacesScreen() { (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 filterableHosts = useMemo(() => { - const counts = new Map(); - for (const workspace of workspaces) { - if (workspace.projectId !== selectedProjectId) continue; - counts.set(workspace.hostId, (counts.get(workspace.hostId) ?? 0) + 1); - } - return (hosts ?? []) - .map((host) => ({ - machineId: host.machineId, - name: host.name, - isOnline: host.isOnline, - workspaceCount: counts.get(host.machineId) ?? 0, - })) - .sort((a, b) => a.name.localeCompare(b.name)); - }, [hosts, workspaces, selectedProjectId]); - const projectNamesById = useMemo( () => new Map((projects ?? []).map((project) => [project.id, project.name])), @@ -245,16 +210,10 @@ export function WorkspacesScreen() { logo={activeOrganization?.logo} onPress={() => setSheetOpen(true)} /> - - setFilterSheetOpen(true)} - /> - setSearchQuery(event.nativeEvent.text)} onCancelButtonPress={() => setSearchQuery("")} /> + + router.push("/(authenticated)/(home)/filter")} + /> + - ); } diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/WorkspaceFilterSheet/WorkspaceFilterSheet.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/WorkspaceFilterSheet/WorkspaceFilterSheet.tsx deleted file mode 100644 index e5cd1abe0aa..00000000000 --- a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/WorkspaceFilterSheet/WorkspaceFilterSheet.tsx +++ /dev/null @@ -1,340 +0,0 @@ -import { BottomSheet, Group, Host, RNHostView } from "@expo/ui/swift-ui"; -import { - background, - environment, - frame, - presentationDetents, - presentationDragIndicator, -} from "@expo/ui/swift-ui/modifiers"; -import Ionicons from "@expo/vector-icons/Ionicons"; -import { type ReactNode, useEffect, useState } from "react"; -import { Pressable, ScrollView, StyleSheet, 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; -} - -export interface FilterableHost { - machineId: string; - name: string; - isOnline: boolean; - workspaceCount: number; -} - -const SORT_OPTIONS: { value: WorkspaceSort; label: string }[] = [ - { label: "Last updated", value: "updatedAt" }, - { label: "Date created", value: "createdAt" }, -]; - -const ONLINE_COLOR = "#3fb950"; - -type FilterView = "root" | "sort" | "project" | "host"; - -function HostStatusDot({ isOnline }: { isOnline: boolean }) { - const theme = useTheme(); - return ( - - ); -} - -function SheetHeader({ - title, - onBack, -}: { - title: string; - onBack?: () => void; -}) { - const theme = useTheme(); - return ( - - {onBack ? ( - - - - ) : null} - - {title} - - - ); -} - -function SheetRow({ - icon, - label, - trailing, - onPress, - isLast, -}: { - icon?: ReactNode; - label: string; - trailing: ReactNode; - onPress: () => void; - isLast?: boolean; -}) { - const theme = useTheme(); - return ( - - {icon ? {icon} : null} - - {label} - - - {trailing} - - - ); -} - -function CheckMark({ visible }: { visible: boolean }) { - const theme = useTheme(); - return visible ? ( - - ) : null; -} - -export function WorkspaceFilterSheet({ - isPresented, - onIsPresentedChange, - projects, - selectedProjectId, - onSelectProject, - hosts, - selectedHostId, - onSelectHost, - sort, - onChangeSort, - width, -}: { - isPresented: boolean; - onIsPresentedChange: (value: boolean) => void; - projects: FilterableProject[]; - selectedProjectId: string | null; - onSelectProject: (projectId: string) => void; - hosts: FilterableHost[]; - selectedHostId: string | null; - onSelectHost: (machineId: string | null) => void; - sort: WorkspaceSort; - onChangeSort: (sort: WorkspaceSort) => void; - width: number; -}) { - const theme = useTheme(); - const [view, setView] = useState("root"); - - useEffect(() => { - if (isPresented) setView("root"); - }, [isPresented]); - - const selectedProject = projects.find( - (project) => project.id === selectedProjectId, - ); - const selectedHost = hosts.find((host) => host.machineId === selectedHostId); - const sortLabel = - SORT_OPTIONS.find((option) => option.value === sort)?.label ?? ""; - - const mutedIcon = (name: keyof typeof Ionicons.glyphMap) => ( - - ); - - const trailingValue = (value: string, accessory?: ReactNode) => ( - <> - {accessory} - - {value} - - - - ); - - return ( - - - - - - {view === "root" ? ( - <> - - - ) : undefined, - )} - onPress={() => setView("project")} - /> - - ) : undefined, - )} - onPress={() => setView("host")} - /> - setView("sort")} - isLast - /> - - ) : null} - {view === "sort" ? ( - <> - setView("root")} /> - {SORT_OPTIONS.map((option, index) => ( - } - onPress={() => { - onChangeSort(option.value); - setView("root"); - }} - isLast={index === SORT_OPTIONS.length - 1} - /> - ))} - - ) : null} - {view === "project" ? ( - <> - setView("root")} /> - - {projects.map((project, index) => ( - - } - label={project.name} - trailing={ - <> - - {project.workspaceCount} - - - - } - onPress={() => { - onSelectProject(project.id); - setView("root"); - }} - isLast={index === projects.length - 1} - /> - ))} - - - ) : null} - {view === "host" ? ( - <> - setView("root")} /> - - } - onPress={() => { - onSelectHost(null); - setView("root"); - }} - /> - {hosts.map((host, index) => ( - } - label={host.name} - trailing={ - <> - - {host.workspaceCount} - - - - } - onPress={() => { - onSelectHost(host.machineId); - setView("root"); - }} - isLast={index === hosts.length - 1} - /> - ))} - - - ) : null} - - - - - - ); -} diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/WorkspaceFilterSheet/index.ts b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/WorkspaceFilterSheet/index.ts deleted file mode 100644 index 3e2aa5db7d3..00000000000 --- a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/WorkspaceFilterSheet/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - type FilterableHost, - type FilterableProject, - WorkspaceFilterSheet, - type WorkspaceSort, -} from "./WorkspaceFilterSheet"; 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..93ae724ff0e --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/stores/workspacesFilterStore/workspacesFilterStore.ts @@ -0,0 +1,28 @@ +import { create } from "zustand"; + +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( + (set) => ({ + projectFilter: null, + hostFilter: null, + sort: "updatedAt", + setProjectFilter: (projectId) => set({ projectFilter: projectId }), + setHostFilter: (machineId) => set({ hostFilter: machineId }), + setSort: (sort) => set({ sort }), + }), +); diff --git a/bun.lock b/bun.lock index b5c6fbbf891..1a6cd4c0471 100644 --- a/bun.lock +++ b/bun.lock @@ -497,7 +497,6 @@ "@shikijs/themes": "4.2.0", "@superset/auth": "workspace:*", "@superset/db": "workspace:*", - "@superset/host-service": "workspace:*", "@superset/shared": "workspace:*", "@superset/trpc": "workspace:*", "@tanstack/db": "0.6.10", @@ -554,6 +553,7 @@ "tokenlens": "1.3.1", "uniwind": "1.8.0", "zod": "4.3.6", + "zustand": "5.0.12", }, "devDependencies": { "@babel/preset-typescript": "7.29.7", From 90554e6de2aa5a9a07b560ed7a09c1e7837c5af0 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Wed, 8 Jul 2026 15:46:51 -0700 Subject: [PATCH 06/30] feat(host-service): ship compiled router type declarations for cross-runtime consumers Expo can't typecheck host-service's Node-flavored source, so mobile carried a hand-written HostServiceClient mirror. host-service (and the workspace deps its router types reach: port-scanner, pty-daemon, workspace-fs) now emit d.ts via build:types; a types-only ./router export exposes AppRouter, and mobile's client is a real createTRPCClient with inferred output aliases. Also exports the handful of types tsc needs nameable for declaration emit. --- .gitignore | 1 + apps/mobile/lib/host-service/client.ts | 74 +++++-------------- apps/mobile/package.json | 2 + bun.lock | 2 + packages/chat/src/server/desktop/index.ts | 3 + packages/host-service/package.json | 4 + packages/host-service/src/trpc/index.ts | 2 +- .../src/trpc/router/project/handlers.ts | 2 +- .../procedures/search-github-issues.ts | 2 +- .../procedures/search-pull-requests.ts | 2 +- packages/host-service/tsconfig.types.json | 12 +++ packages/port-scanner/package.json | 5 +- packages/port-scanner/tsconfig.types.json | 12 +++ packages/pty-daemon/package.json | 9 ++- packages/pty-daemon/tsconfig.types.json | 12 +++ packages/workspace-fs/package.json | 13 ++-- packages/workspace-fs/tsconfig.types.json | 12 +++ turbo.jsonc | 5 +- 18 files changed, 101 insertions(+), 73 deletions(-) create mode 100644 packages/host-service/tsconfig.types.json create mode 100644 packages/port-scanner/tsconfig.types.json create mode 100644 packages/pty-daemon/tsconfig.types.json create mode 100644 packages/workspace-fs/tsconfig.types.json 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/apps/mobile/lib/host-service/client.ts b/apps/mobile/lib/host-service/client.ts index 93d23e1ade0..832629dd13d 100644 --- a/apps/mobile/lib/host-service/client.ts +++ b/apps/mobile/lib/host-service/client.ts @@ -1,63 +1,26 @@ -import type { SelectV2Workspace } from "@superset/db/schema"; +import type { AppRouter } from "@superset/host-service/router"; import { buildHostRoutingKey } from "@superset/shared/host-routing"; -import { createTRPCClient, httpLink } from "@trpc/client"; +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 interface HostWorkspaceRow extends SelectV2Workspace { - worktreePath: string; - worktreeExists: boolean; -} - -export interface ChangedFileStats { - path: string; - additions: number; - deletions: number; -} +export type HostServiceClient = TRPCClient; -export interface GitStatusSnapshot { - againstBase: ChangedFileStats[]; - staged: ChangedFileStats[]; - unstaged: ChangedFileStats[]; -} +type RouterOutputs = inferRouterOutputs; -export interface DestroyWorkspaceResult { - success: boolean; - cloudDeleted: boolean; - worktreeRemoved: boolean; - branchDeleted: boolean; - warnings: string[]; -} - -// Procedures mirrored from packages/host-service/src/trpc/router — the real -// AppRouter type graph is Node-flavored source that cannot compile under -// Expo's TS environment. Inputs are validated by zod on the host. -export interface HostServiceClient { - workspace: { - list: { query(): Promise }; - update: { - mutate(input: { id: string; name?: string }): Promise; - }; - }; - workspaceCleanup: { - destroy: { - mutate(input: { - workspaceId: string; - deleteBranch?: boolean; - force?: boolean; - }): Promise; - }; - }; - git: { - getStatus: { - query(input: { - workspaceId: string; - priority?: "foreground" | "background"; - }): Promise; - }; - }; -} +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(); @@ -72,8 +35,7 @@ export function getHostServiceClientByUrl(hostUrl: string): HostServiceClient { const cached = clientCache.get(hostUrl); if (cached) return cached; - // biome-ignore lint/suspicious/noExplicitAny: see HostServiceClient - const client = createTRPCClient({ + const client = createTRPCClient({ links: [ httpLink({ url: `${hostUrl}/trpc`, @@ -84,7 +46,7 @@ export function getHostServiceClientByUrl(hostUrl: string): HostServiceClient { }, }), ], - }) as unknown as HostServiceClient; + }); clientCache.set(hostUrl, client); return client; diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 25e0153822d..61f5016c553 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -113,6 +113,8 @@ "devDependencies": { "@babel/preset-typescript": "7.29.7", "@gorhom/bottom-sheet": "5.2.14", + "@superset/host-service": "workspace:*", + "@trpc/server": "11.16.0", "@storybook/addon-ondevice-actions": "10.4.2", "@storybook/addon-ondevice-controls": "10.4.2", "@storybook/react-native": "10.4.2", diff --git a/bun.lock b/bun.lock index 1a6cd4c0471..66f25e459de 100644 --- a/bun.lock +++ b/bun.lock @@ -561,6 +561,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/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/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..b3890a3c28f 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 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": { From e1816200c4c63f8673b94706eba605ab1dd16c29 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Wed, 8 Jul 2026 15:47:02 -0700 Subject: [PATCH 07/30] feat(mobile): bottom-docked new chat widget on home Cursor-style composer docked where the tab bar was: prompt + project/base- branch pickers + model selector + Photos/Camera/Files context sheet. Submit uploads attachments to the chosen host, then workspaces.create with the superset agent sugar - the host creates the chat session and sends the first message; on success navigates to the new session's thread. --- apps/mobile/app.config.ts | 2 +- .../(home)/workspaces/WorkspacesScreen.tsx | 2 + .../NewChatWidget/NewChatWidget.tsx | 258 ++++++++++++++++++ .../BranchPickerSheet/BranchPickerSheet.tsx | 202 ++++++++++++++ .../components/BranchPickerSheet/index.ts | 1 + .../components/ContextSheet/ContextSheet.tsx | 119 ++++++++ .../components/ContextSheet/index.ts | 1 + .../TargetPickerSheet/TargetPickerSheet.tsx | 115 ++++++++ .../components/TargetPickerSheet/index.ts | 1 + .../hooks/useCreateChatWorkspace/index.ts | 1 + .../useCreateChatWorkspace.ts | 87 ++++++ .../hooks/useNewChatTargets/index.ts | 5 + .../useNewChatTargets/useNewChatTargets.ts | 131 +++++++++ .../components/NewChatWidget/index.ts | 1 + .../stores/newChatPreferencesStore/index.ts | 1 + .../newChatPreferencesStore.ts | 30 ++ 16 files changed, 956 insertions(+), 1 deletion(-) create mode 100644 apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/NewChatWidget.tsx create mode 100644 apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/BranchPickerSheet/BranchPickerSheet.tsx create mode 100644 apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/BranchPickerSheet/index.ts create mode 100644 apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/ContextSheet/ContextSheet.tsx create mode 100644 apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/ContextSheet/index.ts create mode 100644 apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/TargetPickerSheet/TargetPickerSheet.tsx create mode 100644 apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/TargetPickerSheet/index.ts create mode 100644 apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/hooks/useCreateChatWorkspace/index.ts create mode 100644 apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/hooks/useCreateChatWorkspace/useCreateChatWorkspace.ts create mode 100644 apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/hooks/useNewChatTargets/index.ts create mode 100644 apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/hooks/useNewChatTargets/useNewChatTargets.ts create mode 100644 apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/index.ts create mode 100644 apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/stores/newChatPreferencesStore/index.ts create mode 100644 apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/stores/newChatPreferencesStore/newChatPreferencesStore.ts diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index c56d01f711d..7c4d3ef3519 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -48,7 +48,7 @@ export default ({ config }: ConfigContext) => ({ [ "expo-image-picker", { - photosPermission: + photosPermission: "Superset needs access to your photo library so you can attach images to chat messages.", cameraPermission: false, microphonePermission: false, diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/WorkspacesScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/WorkspacesScreen.tsx index 4af492765b5..18b6b633bcc 100644 --- a/apps/mobile/screens/(authenticated)/(home)/workspaces/WorkspacesScreen.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/WorkspacesScreen.tsx @@ -15,6 +15,7 @@ import { import { THEME } from "@/lib/theme"; import { useOrganizations } from "@/screens/(authenticated)/hooks/useOrganizations"; import { useCollections } from "@/screens/(authenticated)/providers/CollectionsProvider"; +import { NewChatWidget } from "./components/NewChatWidget"; import { OrganizationHeaderButton } from "./components/OrganizationHeaderButton"; import { OrganizationSwitcherSheet } from "./components/OrganizationSwitcherSheet"; import { prStateFor, WorkspaceRow } from "./components/WorkspaceRow"; @@ -257,6 +258,7 @@ export function WorkspacesScreen() { ) : null } /> + + + + + + + + ); +} + +function NewChatWidgetInner({ + workspaces, +}: { + workspaces: HostWorkspaceItem[]; +}) { + const insets = useSafeAreaInsets(); + const { width } = useWindowDimensions(); + const controller = usePromptInputController(); + + const [targetSheetOpen, setTargetSheetOpen] = useState(false); + const [branchSheetOpen, setBranchSheetOpen] = useState(false); + const [contextSheetOpen, setContextSheetOpen] = useState(false); + const [focused, setFocused] = useState(false); + const [explicitTargetKey, setExplicitTargetKey] = useState( + null, + ); + const [baseBranch, setBaseBranch] = useState(null); + + const modelId = useNewChatPreferencesStore((state) => state.modelId); + const setModelId = useNewChatPreferencesStore((state) => state.setModelId); + const setTargetKey = useNewChatPreferencesStore( + (state) => state.setTargetKey, + ); + + const { targets, defaultTarget } = useNewChatTargets(workspaces); + const selectedTarget = + targets.find((target) => target.key === explicitTargetKey) ?? 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 ? ( + + setTargetSheetOpen(true)} + > + + + {selectedTarget?.projectName ?? "No project"} + + + setBranchSheetOpen(true)} + > + + {branchLabel} + + + + + ) : null} + + + setFocused(false)} + onFocus={() => setFocused(true)} + /> + + {expanded ? ( + + + setContextSheetOpen(true)} + > + + + + + + {selectedModel?.label} + + + + + {SUPERSET_CHAT_MODELS.map((model) => ( + + {model.label} + + {model.provider} + + + ))} + + + + + + + ) : null} + + + + { + setExplicitTargetKey(target.key); + setTargetKey(target.key); + setBaseBranch(null); + }} + /> + + + + ); +} diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/BranchPickerSheet/BranchPickerSheet.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/BranchPickerSheet/BranchPickerSheet.tsx new file mode 100644 index 00000000000..85778b49189 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/BranchPickerSheet/BranchPickerSheet.tsx @@ -0,0 +1,202 @@ +import { + BottomSheetBackdrop, + type BottomSheetBackdropProps, + BottomSheetModal, + BottomSheetScrollView, + BottomSheetTextInput, +} from "@gorhom/bottom-sheet"; +import { useQuery } from "@tanstack/react-query"; +import { CheckIcon, SearchIcon } from "lucide-react-native"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Pressable, View } from "react-native"; +import { Icon } from "@/components/ui/icon"; +import { Spinner } from "@/components/ui/spinner"; +import { Text } from "@/components/ui/text"; +import { getHostServiceClientByUrl } from "@/lib/host-service/client"; +import { THEME } from "@/lib/theme"; + +function BranchRow({ + name, + isSelected, + onPress, +}: { + name: string; + isSelected: boolean; + onPress: () => void; +}) { + return ( + + + {name} + + {isSelected ? ( + + ) : null} + + ); +} + +export function BranchPickerSheet({ + open, + onOpenChange, + hostUrl, + projectId, + selectedBranch, + onSelect, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + hostUrl: string | null; + projectId: string | null; + /** Null = default branch. */ + selectedBranch: string | null; + onSelect: (branch: string | null) => void; +}) { + const modalRef = useRef(null); + const [query, setQuery] = useState(""); + + useEffect(() => { + if (open) { + modalRef.current?.present(); + } else { + modalRef.current?.dismiss(); + } + }, [open]); + + const trimmedQuery = query.trim(); + const { data, isLoading } = useQuery({ + queryKey: ["host-service", "branches", hostUrl, projectId, trimmedQuery], + enabled: open && 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 handleDismiss = useCallback(() => { + setQuery(""); + onOpenChange(false); + }, [onOpenChange]); + + const selectAndClose = useCallback( + (branch: string | null) => { + onSelect(branch); + onOpenChange(false); + }, + [onSelect, onOpenChange], + ); + + const renderBackdrop = useCallback( + (backdropProps: BottomSheetBackdropProps) => ( + + ), + [], + ); + + return ( + + + + + + + + {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)/workspaces/components/NewChatWidget/components/BranchPickerSheet/index.ts b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/BranchPickerSheet/index.ts new file mode 100644 index 00000000000..ca77cf357e6 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/BranchPickerSheet/index.ts @@ -0,0 +1 @@ +export { BranchPickerSheet } from "./BranchPickerSheet"; 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..dc0156e1391 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/ContextSheet/ContextSheet.tsx @@ -0,0 +1,119 @@ +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 * as ImagePicker from "expo-image-picker"; +import { Alert, Pressable, View } from "react-native"; +import { usePromptInputAttachments } from "@/components/ai-elements/prompt-input"; +import { Text } from "@/components/ui/text"; +import { useTheme } from "@/hooks/useTheme"; + +// 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; + +export function ContextSheet({ + isPresented, + onIsPresentedChange, + width, +}: { + isPresented: boolean; + onIsPresentedChange: (value: boolean) => void; + width: number; +}) { + const theme = useTheme(); + const attachments = usePromptInputAttachments(); + + const runAfterDismiss = (action: () => void) => { + onIsPresentedChange(false); + setTimeout(action, SHEET_DISMISS_DELAY_MS); + }; + + 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 rows = [ + { + icon: "images-outline" as const, + label: "Photos", + action: () => void attachments.openImagePicker(), + }, + { + icon: "camera-outline" as const, + label: "Camera", + action: () => void openCamera(), + }, + { + icon: "document-outline" as const, + label: "Files", + action: () => void attachments.openFilePicker(), + }, + ]; + + return ( + + + + + + + Add context + + {rows.map((row) => ( + runAfterDismiss(row.action)} + className="flex-row items-center gap-2.5 py-2.5" + > + + + {row.label} + + + ))} + + + + + + ); +} 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/components/TargetPickerSheet/TargetPickerSheet.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/TargetPickerSheet/TargetPickerSheet.tsx new file mode 100644 index 00000000000..429a71cd14c --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/TargetPickerSheet/TargetPickerSheet.tsx @@ -0,0 +1,115 @@ +import { + BottomSheetBackdrop, + type BottomSheetBackdropProps, + BottomSheetModal, + BottomSheetScrollView, +} from "@gorhom/bottom-sheet"; +import { CheckIcon } from "lucide-react-native"; +import { useCallback, useEffect, useRef } from "react"; +import { Pressable, View } from "react-native"; +import { Icon } from "@/components/ui/icon"; +import { Text } from "@/components/ui/text"; +import { THEME } from "@/lib/theme"; +import { ProjectAvatar } from "@/screens/(authenticated)/(home)/filter/components/ProjectAvatar"; +import type { NewChatTarget } from "../../hooks/useNewChatTargets"; + +export function TargetPickerSheet({ + open, + onOpenChange, + targets, + selectedKey, + onSelect, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + targets: NewChatTarget[]; + selectedKey: string | null; + onSelect: (target: NewChatTarget) => void; +}) { + const modalRef = useRef(null); + + useEffect(() => { + if (open) { + modalRef.current?.present(); + } else { + modalRef.current?.dismiss(); + } + }, [open]); + + const renderBackdrop = useCallback( + (backdropProps: BottomSheetBackdropProps) => ( + + ), + [], + ); + + return ( + onOpenChange(false)} + ref={modalRef} + snapPoints={["50%"]} + > + + + Projects + + {targets.length === 0 ? ( + + + No projects on an online host + + + ) : null} + {targets.map((target) => { + const isSelected = target.key === selectedKey; + return ( + { + onSelect(target); + onOpenChange(false); + }} + > + + + + {target.projectName} + + + {target.hostName} + + + {isSelected ? ( + + ) : null} + + ); + })} + + + ); +} diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/TargetPickerSheet/index.ts b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/TargetPickerSheet/index.ts new file mode 100644 index 00000000000..ab45d964d7a --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/TargetPickerSheet/index.ts @@ -0,0 +1 @@ +export { TargetPickerSheet } from "./TargetPickerSheet"; 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..f8c535ecac4 --- /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..2ee1d8cff0b --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/stores/newChatPreferencesStore/newChatPreferencesStore.ts @@ -0,0 +1,30 @@ +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; + setModelId: (modelId: string) => void; + setTargetKey: (targetKey: string) => void; +} + +export const useNewChatPreferencesStore = create()( + persist( + (set) => ({ + modelId: DEFAULT_MODEL_ID, + targetKey: null, + setModelId: (modelId) => set({ modelId }), + setTargetKey: (targetKey) => set({ targetKey }), + }), + { + name: "new-chat-preferences", + storage: createJSONStorage(() => AsyncStorage), + }, + ), +); From 07ffffb8d73fdba0523863e0fb192d7e651d9dea Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Wed, 8 Jul 2026 16:39:27 -0700 Subject: [PATCH 08/30] feat(mobile): recent-photos carousel with multi-select in the context sheet Cursor-style: permission placeholder until photo access is granted, then a horizontal carousel of recent library photos with numbered multi-select and an Add N button. Selected assets transcode to JPEG (library originals are HEIC, which the agent API rejects). Sheet moved from @expo/ui to @gorhom/bottom-sheet for the scrollable carousel. --- apps/mobile/app.config.ts | 9 +- apps/mobile/package.json | 2 + .../NewChatWidget/NewChatWidget.tsx | 7 +- .../BranchPickerSheet/BranchPickerSheet.tsx | 73 ++++-- .../components/ContextSheet/ContextSheet.tsx | 215 +++++++++++++----- .../PhotoCarousel/PhotoCarousel.tsx | 127 +++++++++++ .../components/PhotoCarousel/index.ts | 1 + bun.lock | 6 + 8 files changed, 351 insertions(+), 89 deletions(-) create mode 100644 apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/ContextSheet/components/PhotoCarousel/PhotoCarousel.tsx create mode 100644 apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/ContextSheet/components/PhotoCarousel/index.ts diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 7c4d3ef3519..7f3bfa549c3 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -54,7 +54,14 @@ export default ({ config }: ConfigContext) => ({ microphonePermission: false, }, ], - "expo-document-picker", + "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/package.json b/apps/mobile/package.json index 61f5016c553..6c595338619 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -76,9 +76,11 @@ "expo-file-system": "56.0.7", "expo-glass-effect": "56.0.4", "expo-image": "56.0.10", + "expo-image-manipulator": "56.0.20", "expo-image-picker": "56.0.16", "expo-linking": "56.0.13", "expo-localization": "56.0.6", + "expo-media-library": "~56.0.9", "expo-network": "56.0.5", "expo-router": "56.2.9", "expo-secure-store": "56.0.4", diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/NewChatWidget.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/NewChatWidget.tsx index 777d51eae36..1b08c20a42e 100644 --- a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/NewChatWidget.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/NewChatWidget.tsx @@ -8,7 +8,6 @@ import { KeyboardAvoidingView, Pressable, StyleSheet, - useWindowDimensions, View, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -69,7 +68,6 @@ function NewChatWidgetInner({ workspaces: HostWorkspaceItem[]; }) { const insets = useSafeAreaInsets(); - const { width } = useWindowDimensions(); const controller = usePromptInputController(); const [targetSheetOpen, setTargetSheetOpen] = useState(false); @@ -249,9 +247,8 @@ function NewChatWidgetInner({ onSelect={setBaseBranch} /> ); diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/BranchPickerSheet/BranchPickerSheet.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/BranchPickerSheet/BranchPickerSheet.tsx index 85778b49189..c89edd76457 100644 --- a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/BranchPickerSheet/BranchPickerSheet.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/BranchPickerSheet/BranchPickerSheet.tsx @@ -6,9 +6,9 @@ import { BottomSheetTextInput, } from "@gorhom/bottom-sheet"; import { useQuery } from "@tanstack/react-query"; -import { CheckIcon, SearchIcon } from "lucide-react-native"; +import { CheckIcon, SearchIcon, XIcon } from "lucide-react-native"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Pressable, View } from "react-native"; +import { Pressable, StyleSheet, View } from "react-native"; import { Icon } from "@/components/ui/icon"; import { Spinner } from "@/components/ui/spinner"; import { Text } from "@/components/ui/text"; @@ -18,29 +18,47 @@ import { THEME } from "@/lib/theme"; function BranchRow({ name, isSelected, + isLast, onPress, }: { name: string; isSelected: boolean; + isLast?: boolean; onPress: () => void; }) { return ( - + {name} {isSelected ? ( - + ) : null} ); } +function SectionLabel({ children }: { children: string }) { + return ( + + {children} + + ); +} + export function BranchPickerSheet({ open, onOpenChange, @@ -122,20 +140,29 @@ export function BranchPickerSheet({ - - - + + + onOpenChange(false)} + > + + + + Branch + + + + {defaultBranch ? ( <> - - Default - + Default selectAndClose(null)} + isLast /> ) : null} {branches.length > 0 ? ( - - {trimmedQuery ? "Branches" : "Recents"} - + {trimmedQuery ? "Branches" : "Recents"} ) : null} - {branches.map((branch) => ( + {branches.map((branch, index) => ( selectAndClose(branch.name)} + isLast={index === branches.length - 1} /> ))} {isLoading && !data ? ( 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 index dc0156e1391..99459b5ae48 100644 --- 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 @@ -1,34 +1,63 @@ -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 { + BottomSheetBackdrop, + type BottomSheetBackdropProps, + BottomSheetModal, + BottomSheetView, +} from "@gorhom/bottom-sheet"; +import { manipulateAsync, SaveFormat } from "expo-image-manipulator"; import * as ImagePicker from "expo-image-picker"; +import * as MediaLibrary from "expo-media-library/legacy"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Alert, Pressable, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; 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 { THEME } from "@/lib/theme"; +import { PhotoCarousel } from "./components/PhotoCarousel"; // 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; export function ContextSheet({ - isPresented, - onIsPresentedChange, - width, + open, + onOpenChange, }: { - isPresented: boolean; - onIsPresentedChange: (value: boolean) => void; - width: number; + open: boolean; + onOpenChange: (open: boolean) => void; }) { - const theme = useTheme(); + const insets = useSafeAreaInsets(); const attachments = usePromptInputAttachments(); + const modalRef = useRef(null); + const [selected, setSelected] = useState([]); + const [adding, setAdding] = useState(false); + + useEffect(() => { + if (open) { + modalRef.current?.present(); + } else { + modalRef.current?.dismiss(); + } + }, [open]); + + const handleDismiss = useCallback(() => { + setSelected([]); + onOpenChange(false); + }, [onOpenChange]); + + 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) => { - onIsPresentedChange(false); + onOpenChange(false); setTimeout(action, SHEET_DISMISS_DELAY_MS); }; @@ -51,6 +80,40 @@ export function ContextSheet({ ); }; + 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); + setSelected([]); + onOpenChange(false); + } catch (error) { + Alert.alert( + "Could not add photos", + error instanceof Error ? error.message : String(error), + ); + } finally { + setAdding(false); + } + }; + const rows = [ { icon: "images-outline" as const, @@ -69,51 +132,85 @@ export function ContextSheet({ }, ]; + const renderBackdrop = useCallback( + (backdropProps: BottomSheetBackdropProps) => ( + + ), + [], + ); + return ( - - - - - - - Add context + + + + onOpenChange(false)} + > + + + Context + + + + + Add + + {rows.map((row) => ( + runAfterDismiss(row.action)} + className="flex-row items-center gap-2.5 py-2.5" + > + + + {row.label} - {rows.map((row) => ( - runAfterDismiss(row.action)} - className="flex-row items-center gap-2.5 py-2.5" - > - - - {row.label} - - - ))} - - - - - + + ))} + + {selected.length > 0 ? ( + + + + ) : null} + + ); } 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/bun.lock b/bun.lock index 66f25e459de..95d82570c49 100644 --- a/bun.lock +++ b/bun.lock @@ -521,9 +521,11 @@ "expo-file-system": "56.0.7", "expo-glass-effect": "56.0.4", "expo-image": "56.0.10", + "expo-image-manipulator": "56.0.20", "expo-image-picker": "56.0.16", "expo-linking": "56.0.13", "expo-localization": "56.0.6", + "expo-media-library": "~56.0.9", "expo-network": "56.0.5", "expo-router": "56.2.9", "expo-secure-store": "56.0.4", @@ -4247,6 +4249,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.20", "", { "dependencies": { "expo-image-loader": "~56.0.3" }, "peerDependencies": { "expo": "*" } }, "sha512-Ma0mpY1FoV52xOOCT+2lbzGj1t79KhS8brmcrNvKJzih+DF8hRnuycuwoe0gog5zhJ772/f46UdGxx6OBMxu9g=="], + "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=="], @@ -4261,6 +4265,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.9", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-SD92CiE/UPXpDmvSrOWtMX12TeGhN4w9ZdePNNxa43bC9uTnEAS3YTLAkNaFHHbCSUlWSNVLW4yPZhNgugAO/g=="], + "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=="], From f9a58ecc7c5f1232769cb1ca8f020f5a1a632f77 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Wed, 8 Jul 2026 16:40:28 -0700 Subject: [PATCH 09/30] fix(mobile): drop ineffective biome suppression in useVisibleDiffStats --- .../useVisibleDiffStats.ts | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/hooks/useVisibleDiffStats/useVisibleDiffStats.ts b/apps/mobile/screens/(authenticated)/(home)/workspaces/hooks/useVisibleDiffStats/useVisibleDiffStats.ts index 1a418a8863e..977452c61ca 100644 --- a/apps/mobile/screens/(authenticated)/(home)/workspaces/hooks/useVisibleDiffStats/useVisibleDiffStats.ts +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/hooks/useVisibleDiffStats/useVisibleDiffStats.ts @@ -1,4 +1,4 @@ -import { useQueries } from "@tanstack/react-query"; +import { useQueries, useQueryClient } from "@tanstack/react-query"; import { useMemo } from "react"; import type { HostWorkspaceItem } from "@/hooks/useHostWorkspaces"; import { @@ -12,6 +12,7 @@ export interface DiffStats { } const DIFF_STATS_STALE_MS = 60_000; +const DIFF_STATS_GC_MS = 30 * 60_000; function aggregateDiffStats(status: GitStatusSnapshot): DiffStats { const byPath = new Map(); @@ -32,8 +33,10 @@ function aggregateDiffStats(status: GitStatusSnapshot): DiffStats { } /** - * Live working-tree diff stats for the workspaces currently settled in the - * viewport — bounded per screenful, not per project. + * 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, @@ -44,6 +47,8 @@ export function useVisibleDiffStats({ 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) { @@ -62,10 +67,11 @@ export function useVisibleDiffStats({ return result; }, [visibleIds, workspacesById, resolveHostUrl]); - const queries = useQueries({ + 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: () => @@ -78,10 +84,17 @@ export function useVisibleDiffStats({ return useMemo(() => { const map = new Map(); - eligible.forEach(({ workspaceId }, index) => { - const status = queries[index]?.data; - if (status) map.set(workspaceId, aggregateDiffStats(status)); - }); + 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; - }, [eligible, queries]); + }, [queryClient]); } From 9a8f743f72622de156a1d2cf700e64019a2e9fb3 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Wed, 8 Jul 2026 16:48:02 -0700 Subject: [PATCH 10/30] fix(mobile): pin expo-image-manipulator to 56.0.17 to match expo-modules-core 56.0.15 56.0.20's precompiled framework references Record.from(dictionary:appContext:) which core 56.0.15 lacks - the app crashed at launch with a DYLD missing-symbol abort. Expo module patch releases pair with the core released the same day; bun's minimum-release-age holds core back, so match by release date. --- apps/mobile/package.json | 2 +- bun.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 6c595338619..78831e36fde 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -76,7 +76,7 @@ "expo-file-system": "56.0.7", "expo-glass-effect": "56.0.4", "expo-image": "56.0.10", - "expo-image-manipulator": "56.0.20", + "expo-image-manipulator": "56.0.17", "expo-image-picker": "56.0.16", "expo-linking": "56.0.13", "expo-localization": "56.0.6", diff --git a/bun.lock b/bun.lock index 95d82570c49..103078a6948 100644 --- a/bun.lock +++ b/bun.lock @@ -521,7 +521,7 @@ "expo-file-system": "56.0.7", "expo-glass-effect": "56.0.4", "expo-image": "56.0.10", - "expo-image-manipulator": "56.0.20", + "expo-image-manipulator": "56.0.17", "expo-image-picker": "56.0.16", "expo-linking": "56.0.13", "expo-localization": "56.0.6", @@ -4249,7 +4249,7 @@ "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.20", "", { "dependencies": { "expo-image-loader": "~56.0.3" }, "peerDependencies": { "expo": "*" } }, "sha512-Ma0mpY1FoV52xOOCT+2lbzGj1t79KhS8brmcrNvKJzih+DF8hRnuycuwoe0gog5zhJ772/f46UdGxx6OBMxu9g=="], + "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=="], From d64376e3dec8665231d0d551d53dd42b40b097f9 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Wed, 8 Jul 2026 17:09:49 -0700 Subject: [PATCH 11/30] fix(mobile): align expo native modules to the expo-modules-core 56.0.15 batch Precompiled Expo frameworks require module and core from the same release batch in BOTH directions (core 56.0.19 removed a symbol ExpoFileSystem 56.0.7 needs; media-library 56.0.9 needs a symbol core 56.0.15 lacks). Pin core 56.0.15 explicitly and take media-library 56.0.6 + image-manipulator 56.0.17 to match the batch everything else already runs on. --- apps/mobile/package.json | 3 ++- bun.lock | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 78831e36fde..bd740294cd3 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -80,7 +80,8 @@ "expo-image-picker": "56.0.16", "expo-linking": "56.0.13", "expo-localization": "56.0.6", - "expo-media-library": "~56.0.9", + "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", diff --git a/bun.lock b/bun.lock index 103078a6948..faa50fc75b7 100644 --- a/bun.lock +++ b/bun.lock @@ -525,7 +525,8 @@ "expo-image-picker": "56.0.16", "expo-linking": "56.0.13", "expo-localization": "56.0.6", - "expo-media-library": "~56.0.9", + "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", @@ -4265,7 +4266,7 @@ "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.9", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-SD92CiE/UPXpDmvSrOWtMX12TeGhN4w9ZdePNNxa43bC9uTnEAS3YTLAkNaFHHbCSUlWSNVLW4yPZhNgugAO/g=="], + "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=="], From 50287346c7c72eca3d0091e3e9ac3254fc99c668 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Wed, 8 Jul 2026 17:15:09 -0700 Subject: [PATCH 12/30] =?UTF-8?q?feat(mobile):=20settings=20first=20pass?= =?UTF-8?q?=20=E2=80=94=20account,=20organization,=20hosts,=20billing,=20l?= =?UTF-8?q?og=20out?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - native-stack settings screens flattened into the authenticated stack (automatic back chevron); placeholder cards die - account (avatar/name/email), organization (org header + members list joined from members/users collections), hosts (status dot + workspace count + last-seen), billing (read-only plan from a new org-scoped subscriptions Electric collection) - destructive log-out row with confirm alert - filter row primitives promoted to screens/(authenticated)/components as ListRow/ListRowValue/ListRowCheck/HostStatusDot (+ OrganizationAvatar) and reused across filter + settings - org switcher trimmed: orgs, separator, settings, log out; bare slugs --- apps/mobile/app/(authenticated)/_layout.tsx | 28 ++- .../app/(authenticated)/settings/account.tsx | 3 + .../app/(authenticated)/settings/billing.tsx | 3 + .../app/(authenticated)/settings/hosts.tsx | 3 + .../(authenticated)/settings/organization.tsx | 3 + apps/mobile/lib/collections/collections.ts | 18 ++ .../(home)/filter/FilterScreen.tsx | 18 +- .../filter/components/FilterCheck/index.ts | 1 - .../filter/components/FilterRow/index.ts | 1 - .../filter/components/FilterRowValue/index.ts | 1 - .../(home)/filter/host/HostFilterScreen.tsx | 14 +- .../filter/project/ProjectFilterScreen.tsx | 8 +- .../(home)/filter/sort/SortFilterScreen.tsx | 8 +- .../OrganizationHeaderButton.tsx | 2 +- .../OrganizationSwitcherSheet.tsx | 35 +--- .../workspacesFilterStore.ts | 26 ++- .../HostStatusDot/HostStatusDot.tsx | 0 .../components/HostStatusDot/index.ts | 0 .../ListRow/ListRow.tsx} | 31 ++- .../components/ListRow/index.ts | 1 + .../ListRowCheck/ListRowCheck.tsx} | 2 +- .../components/ListRowCheck/index.ts | 1 + .../ListRowValue/ListRowValue.tsx} | 16 +- .../components/ListRowValue/index.ts | 1 + .../OrganizationAvatar/OrganizationAvatar.tsx | 0 .../components/OrganizationAvatar/index.ts | 0 .../settings/SettingsScreen.tsx | 176 +++++++++++++----- .../account/AccountSettingsScreen.tsx | 47 +++++ .../(authenticated)/settings/account/index.ts | 1 + .../billing/BillingSettingsScreen.tsx | 84 +++++++++ .../(authenticated)/settings/billing/index.ts | 1 + .../components/UserAvatar/UserAvatar.tsx | 30 +++ .../settings/components/UserAvatar/index.ts | 1 + .../settings/hosts/HostsSettingsScreen.tsx | 57 ++++++ .../(authenticated)/settings/hosts/index.ts | 1 + .../OrganizationSettingsScreen.tsx | 89 +++++++++ .../settings/organization/index.ts | 1 + .../activeSubscription/activeSubscription.ts | 11 ++ .../utils/activeSubscription/index.ts | 1 + 39 files changed, 591 insertions(+), 133 deletions(-) create mode 100644 apps/mobile/app/(authenticated)/settings/account.tsx create mode 100644 apps/mobile/app/(authenticated)/settings/billing.tsx create mode 100644 apps/mobile/app/(authenticated)/settings/hosts.tsx create mode 100644 apps/mobile/app/(authenticated)/settings/organization.tsx delete mode 100644 apps/mobile/screens/(authenticated)/(home)/filter/components/FilterCheck/index.ts delete mode 100644 apps/mobile/screens/(authenticated)/(home)/filter/components/FilterRow/index.ts delete mode 100644 apps/mobile/screens/(authenticated)/(home)/filter/components/FilterRowValue/index.ts rename apps/mobile/screens/(authenticated)/{(home)/filter => }/components/HostStatusDot/HostStatusDot.tsx (100%) rename apps/mobile/screens/(authenticated)/{(home)/filter => }/components/HostStatusDot/index.ts (100%) rename apps/mobile/screens/(authenticated)/{(home)/filter/components/FilterRow/FilterRow.tsx => components/ListRow/ListRow.tsx} (60%) create mode 100644 apps/mobile/screens/(authenticated)/components/ListRow/index.ts rename apps/mobile/screens/(authenticated)/{(home)/filter/components/FilterCheck/FilterCheck.tsx => components/ListRowCheck/ListRowCheck.tsx} (77%) create mode 100644 apps/mobile/screens/(authenticated)/components/ListRowCheck/index.ts rename apps/mobile/screens/(authenticated)/{(home)/filter/components/FilterRowValue/FilterRowValue.tsx => components/ListRowValue/ListRowValue.tsx} (70%) create mode 100644 apps/mobile/screens/(authenticated)/components/ListRowValue/index.ts rename apps/mobile/screens/(authenticated)/{(home)/workspaces/components/OrganizationSwitcherSheet => }/components/OrganizationAvatar/OrganizationAvatar.tsx (100%) rename apps/mobile/screens/(authenticated)/{(home)/workspaces/components/OrganizationSwitcherSheet => }/components/OrganizationAvatar/index.ts (100%) create mode 100644 apps/mobile/screens/(authenticated)/settings/account/AccountSettingsScreen.tsx create mode 100644 apps/mobile/screens/(authenticated)/settings/account/index.ts create mode 100644 apps/mobile/screens/(authenticated)/settings/billing/BillingSettingsScreen.tsx create mode 100644 apps/mobile/screens/(authenticated)/settings/billing/index.ts create mode 100644 apps/mobile/screens/(authenticated)/settings/components/UserAvatar/UserAvatar.tsx create mode 100644 apps/mobile/screens/(authenticated)/settings/components/UserAvatar/index.ts create mode 100644 apps/mobile/screens/(authenticated)/settings/hosts/HostsSettingsScreen.tsx create mode 100644 apps/mobile/screens/(authenticated)/settings/hosts/index.ts create mode 100644 apps/mobile/screens/(authenticated)/settings/organization/OrganizationSettingsScreen.tsx create mode 100644 apps/mobile/screens/(authenticated)/settings/organization/index.ts create mode 100644 apps/mobile/screens/(authenticated)/settings/utils/activeSubscription/activeSubscription.ts create mode 100644 apps/mobile/screens/(authenticated)/settings/utils/activeSubscription/index.ts diff --git a/apps/mobile/app/(authenticated)/_layout.tsx b/apps/mobile/app/(authenticated)/_layout.tsx index f2927a2f897..98b4688e421 100644 --- a/apps/mobile/app/(authenticated)/_layout.tsx +++ b/apps/mobile/app/(authenticated)/_layout.tsx @@ -2,6 +2,13 @@ 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(); @@ -10,7 +17,26 @@ export default function AuthenticatedLayout() { - + + + + + ); 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/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/lib/collections/collections.ts b/apps/mobile/lib/collections/collections.ts index c3f0da2e579..f6a45935a07 100644 --- a/apps/mobile/lib/collections/collections.ts +++ b/apps/mobile/lib/collections/collections.ts @@ -10,6 +10,7 @@ import type { SelectMember, SelectOrganization, SelectProject, + SelectSubscription, SelectTask, SelectTaskStatus, SelectUser, @@ -69,6 +70,7 @@ interface OrgCollections { v2Projects: Collection; v2Workspaces: Collection; v2Hosts: Collection; + subscriptions: Collection; chatSessions: Collection; githubPullRequests: Collection; } @@ -239,6 +241,21 @@ function createOrgCollections(organizationId: string): OrgCollections { }), ); + const subscriptions = createCollection( + electricCollectionOptions({ + id: `subscriptions-${organizationId}`, + shapeOptions: { + url: electricUrl, + params: { table: "subscriptions", organizationId }, + headers: electricHeaders, + columnMapper, + parser, + onError: handleElectricSyncError, + }, + getKey: (item) => item.id, + }), + ); + const githubPullRequests = createCollection( electricCollectionOptions({ id: `github-pull-requests-${organizationId}`, @@ -279,6 +296,7 @@ function createOrgCollections(organizationId: string): OrgCollections { v2Projects, v2Workspaces, v2Hosts, + subscriptions, chatSessions, githubPullRequests, }; diff --git a/apps/mobile/screens/(authenticated)/(home)/filter/FilterScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/filter/FilterScreen.tsx index 3fa60dbf818..1f12660d29b 100644 --- a/apps/mobile/screens/(authenticated)/(home)/filter/FilterScreen.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/filter/FilterScreen.tsx @@ -7,10 +7,10 @@ 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 { FilterRow } from "./components/FilterRow"; -import { FilterRowValue } from "./components/FilterRowValue"; -import { HostStatusDot } from "./components/HostStatusDot"; import { ProjectAvatar } from "./components/ProjectAvatar"; export function FilterScreen() { @@ -46,7 +46,7 @@ export function FilterScreen() { return ( - router.push("/(authenticated)/(home)/filter/project")} /> - router.push("/(authenticated)/(home)/filter/host")} /> - } label="Sort" - trailing={} + trailing={} onPress={() => router.push("/(authenticated)/(home)/filter/sort")} isLast /> diff --git a/apps/mobile/screens/(authenticated)/(home)/filter/components/FilterCheck/index.ts b/apps/mobile/screens/(authenticated)/(home)/filter/components/FilterCheck/index.ts deleted file mode 100644 index 41014e17749..00000000000 --- a/apps/mobile/screens/(authenticated)/(home)/filter/components/FilterCheck/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { FilterCheck } from "./FilterCheck"; diff --git a/apps/mobile/screens/(authenticated)/(home)/filter/components/FilterRow/index.ts b/apps/mobile/screens/(authenticated)/(home)/filter/components/FilterRow/index.ts deleted file mode 100644 index 1eff2cc8e33..00000000000 --- a/apps/mobile/screens/(authenticated)/(home)/filter/components/FilterRow/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { FilterRow } from "./FilterRow"; diff --git a/apps/mobile/screens/(authenticated)/(home)/filter/components/FilterRowValue/index.ts b/apps/mobile/screens/(authenticated)/(home)/filter/components/FilterRowValue/index.ts deleted file mode 100644 index 23a98cfbefb..00000000000 --- a/apps/mobile/screens/(authenticated)/(home)/filter/components/FilterRowValue/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { FilterRowValue } from "./FilterRowValue"; diff --git a/apps/mobile/screens/(authenticated)/(home)/filter/host/HostFilterScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/filter/host/HostFilterScreen.tsx index d1ed0ed2c76..50833ea3495 100644 --- a/apps/mobile/screens/(authenticated)/(home)/filter/host/HostFilterScreen.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/filter/host/HostFilterScreen.tsx @@ -5,10 +5,10 @@ import { ScrollView, Text } from "react-native"; import { useHostWorkspaces } from "@/hooks/useHostWorkspaces"; import { useTheme } from "@/hooks/useTheme"; 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"; -import { FilterCheck } from "../components/FilterCheck"; -import { FilterRow } from "../components/FilterRow"; -import { HostStatusDot } from "../components/HostStatusDot"; export function HostFilterScreen() { const router = useRouter(); @@ -66,13 +66,13 @@ export function HostFilterScreen() { className="bg-background flex-1" contentContainerClassName="px-6 pb-10" > - } + trailing={} onPress={() => selectHost(null)} /> {sortedHosts.map((host, index) => ( - } label={host.name} @@ -84,7 +84,7 @@ export function HostFilterScreen() { > {host.workspaceCount} - + } onPress={() => selectHost(host.machineId)} diff --git a/apps/mobile/screens/(authenticated)/(home)/filter/project/ProjectFilterScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/filter/project/ProjectFilterScreen.tsx index 1d01697972d..01386a24990 100644 --- a/apps/mobile/screens/(authenticated)/(home)/filter/project/ProjectFilterScreen.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/filter/project/ProjectFilterScreen.tsx @@ -5,9 +5,9 @@ import { ScrollView, Text } from "react-native"; import { useHostWorkspaces } from "@/hooks/useHostWorkspaces"; import { useTheme } from "@/hooks/useTheme"; 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 { FilterCheck } from "../components/FilterCheck"; -import { FilterRow } from "../components/FilterRow"; import { ProjectAvatar } from "../components/ProjectAvatar"; export function ProjectFilterScreen() { @@ -51,7 +51,7 @@ export function ProjectFilterScreen() { contentContainerClassName="px-6 pb-10" > {sortedProjects.map((project, index) => ( - {workspaceCounts.get(project.id) ?? 0} - + } onPress={() => { diff --git a/apps/mobile/screens/(authenticated)/(home)/filter/sort/SortFilterScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/filter/sort/SortFilterScreen.tsx index e72861ba15c..bcf829340dd 100644 --- a/apps/mobile/screens/(authenticated)/(home)/filter/sort/SortFilterScreen.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/filter/sort/SortFilterScreen.tsx @@ -4,8 +4,8 @@ import { SORT_OPTIONS, useWorkspacesFilterStore, } from "@/screens/(authenticated)/(home)/workspaces/stores/workspacesFilterStore"; -import { FilterCheck } from "../components/FilterCheck"; -import { FilterRow } from "../components/FilterRow"; +import { ListRow } from "@/screens/(authenticated)/components/ListRow"; +import { ListRowCheck } from "@/screens/(authenticated)/components/ListRowCheck"; export function SortFilterScreen() { const router = useRouter(); @@ -15,10 +15,10 @@ export function SortFilterScreen() { return ( {SORT_OPTIONS.map((option, index) => ( - } + trailing={} onPress={() => { setSort(option.value); router.back(); diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationHeaderButton/OrganizationHeaderButton.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationHeaderButton/OrganizationHeaderButton.tsx index b8a34a2e6e7..e3920f64eb6 100644 --- a/apps/mobile/screens/(authenticated)/(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)/(home)/workspaces/components/OrganizationSwitcherSheet/OrganizationSwitcherSheet.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationSwitcherSheet/OrganizationSwitcherSheet.tsx index 93e1e7fc1c5..b704e86ab56 100644 --- a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationSwitcherSheet/OrganizationSwitcherSheet.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationSwitcherSheet/OrganizationSwitcherSheet.tsx @@ -10,8 +10,7 @@ import { Pressable, View } from "react-native"; import { Text } from "@/components/ui/text"; import { useSignOut } from "@/hooks/useSignOut"; import { useTheme } from "@/hooks/useTheme"; -import { useSession } from "@/lib/auth/client"; -import { OrganizationAvatar } from "./components/OrganizationAvatar"; +import { OrganizationAvatar } from "@/screens/(authenticated)/components/OrganizationAvatar"; export interface Organization { id: string; @@ -37,7 +36,6 @@ export function OrganizationSwitcherSheet({ }) { const theme = useTheme(); const router = useRouter(); - const { data: session } = useSession(); const { signOut, isSigningOut } = useSignOut(); const handleSignOut = () => { @@ -99,7 +97,7 @@ export function OrganizationSwitcherSheet({ color: theme.mutedForeground, }} > - superset.sh/{organization.slug} + {organization.slug} ) : null} @@ -117,35 +115,6 @@ export function OrganizationSwitcherSheet({ className="my-3 h-px" style={{ backgroundColor: theme.border }} /> - - Settings - - {session?.user ? ( - - - - - {session.user.name} - - - {session.user.email} - - - - ) : null} void; } -export const useWorkspacesFilterStore = create( - (set) => ({ - projectFilter: null, - hostFilter: null, - sort: "updatedAt", - setProjectFilter: (projectId) => set({ projectFilter: projectId }), - setHostFilter: (machineId) => set({ hostFilter: machineId }), - setSort: (sort) => set({ sort }), - }), +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)/filter/components/HostStatusDot/HostStatusDot.tsx b/apps/mobile/screens/(authenticated)/components/HostStatusDot/HostStatusDot.tsx similarity index 100% rename from apps/mobile/screens/(authenticated)/(home)/filter/components/HostStatusDot/HostStatusDot.tsx rename to apps/mobile/screens/(authenticated)/components/HostStatusDot/HostStatusDot.tsx diff --git a/apps/mobile/screens/(authenticated)/(home)/filter/components/HostStatusDot/index.ts b/apps/mobile/screens/(authenticated)/components/HostStatusDot/index.ts similarity index 100% rename from apps/mobile/screens/(authenticated)/(home)/filter/components/HostStatusDot/index.ts rename to apps/mobile/screens/(authenticated)/components/HostStatusDot/index.ts diff --git a/apps/mobile/screens/(authenticated)/(home)/filter/components/FilterRow/FilterRow.tsx b/apps/mobile/screens/(authenticated)/components/ListRow/ListRow.tsx similarity index 60% rename from apps/mobile/screens/(authenticated)/(home)/filter/components/FilterRow/FilterRow.tsx rename to apps/mobile/screens/(authenticated)/components/ListRow/ListRow.tsx index 3a2efc323b6..b511cca4d36 100644 --- a/apps/mobile/screens/(authenticated)/(home)/filter/components/FilterRow/FilterRow.tsx +++ b/apps/mobile/screens/(authenticated)/components/ListRow/ListRow.tsx @@ -3,23 +3,28 @@ import { Pressable, StyleSheet, View } from "react-native"; import { Text } from "@/components/ui/text"; import { useTheme } from "@/hooks/useTheme"; -export function FilterRow({ +export function ListRow({ icon, label, + subtitle, trailing, onPress, isLast, + destructive, }: { icon?: ReactNode; label: string; + subtitle?: string; trailing?: ReactNode; - onPress: () => void; + onPress?: () => void; isLast?: boolean; + destructive?: boolean; }) { const theme = useTheme(); return ( {icon ? {icon} : null} - - {label} - + + + {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)/(home)/filter/components/FilterCheck/FilterCheck.tsx b/apps/mobile/screens/(authenticated)/components/ListRowCheck/ListRowCheck.tsx similarity index 77% rename from apps/mobile/screens/(authenticated)/(home)/filter/components/FilterCheck/FilterCheck.tsx rename to apps/mobile/screens/(authenticated)/components/ListRowCheck/ListRowCheck.tsx index 3ba27de86c6..265a691ddf0 100644 --- a/apps/mobile/screens/(authenticated)/(home)/filter/components/FilterCheck/FilterCheck.tsx +++ b/apps/mobile/screens/(authenticated)/components/ListRowCheck/ListRowCheck.tsx @@ -1,7 +1,7 @@ import Ionicons from "@expo/vector-icons/Ionicons"; import { useTheme } from "@/hooks/useTheme"; -export function FilterCheck({ visible }: { visible: boolean }) { +export function ListRowCheck({ visible }: { visible: boolean }) { const theme = useTheme(); return visible ? ( 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)/(home)/filter/components/FilterRowValue/FilterRowValue.tsx b/apps/mobile/screens/(authenticated)/components/ListRowValue/ListRowValue.tsx similarity index 70% rename from apps/mobile/screens/(authenticated)/(home)/filter/components/FilterRowValue/FilterRowValue.tsx rename to apps/mobile/screens/(authenticated)/components/ListRowValue/ListRowValue.tsx index 39ec2dbb356..ba9966e69a5 100644 --- a/apps/mobile/screens/(authenticated)/(home)/filter/components/FilterRowValue/FilterRowValue.tsx +++ b/apps/mobile/screens/(authenticated)/components/ListRowValue/ListRowValue.tsx @@ -3,12 +3,14 @@ import type { ReactNode } from "react"; import { Text } from "@/components/ui/text"; import { useTheme } from "@/hooks/useTheme"; -export function FilterRowValue({ +export function ListRowValue({ value, accessory, + chevron = true, }: { value: string; accessory?: ReactNode; + chevron?: boolean; }) { const theme = useTheme(); return ( @@ -21,11 +23,13 @@ export function FilterRowValue({ > {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)/(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)/(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)/(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)/(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)/settings/SettingsScreen.tsx b/apps/mobile/screens/(authenticated)/settings/SettingsScreen.tsx index 0f0d688a1dd..80d3be432d3 100644 --- a/apps/mobile/screens/(authenticated)/settings/SettingsScreen.tsx +++ b/apps/mobile/screens/(authenticated)/settings/SettingsScreen.tsx @@ -1,61 +1,137 @@ +import Ionicons from "@expo/vector-icons/Ionicons"; +import { useLiveQuery } from "@tanstack/react-db"; 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"; +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 insets = useSafeAreaInsets(); + const theme = useTheme(); + const collections = useCollections(); + const { data: session } = useSession(); + const { activeOrganization } = useOrganizations(); + const { signOut, isSigningOut } = useSignOut(); - return ( - - - - router.back()} className="p-1"> - - - Settings - + const { data: subscriptions } = useLiveQuery( + (q) => q.from({ subscriptions: collections.subscriptions }), + [collections], + ); - - - Account - - - - Account settings will appear here - - - + const plan = activeSubscription(subscriptions ?? [])?.plan; + const user = session?.user; - - - Appearance - - - - Theme and display settings will appear here - - - + const handleSignOut = () => { + Alert.alert("Log out?", undefined, [ + { style: "cancel", text: "Cancel" }, + { + onPress: () => void signOut(), + style: "destructive", + text: "Log out", + }, + ]); + }; - - - Notifications - - - - Notification preferences will appear here - - - - + 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..1282b45b6a7 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/settings/hosts/HostsSettingsScreen.tsx @@ -0,0 +1,57 @@ +import { useLiveQuery } from "@tanstack/react-db"; +import { formatDistanceToNow } from "date-fns"; +import { useMemo } from "react"; +import { ScrollView, Text } from "react-native"; +import { useHostWorkspaces } from "@/hooks/useHostWorkspaces"; +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 { workspaces } = useHostWorkspaces(); + + const { data: hosts } = useLiveQuery( + (q) => q.from({ v2Hosts: collections.v2Hosts }), + [collections], + ); + + const hostRows = useMemo(() => { + const counts = new Map(); + for (const workspace of workspaces) { + counts.set(workspace.hostId, (counts.get(workspace.hostId) ?? 0) + 1); + } + return [...(hosts ?? [])] + .sort((a, b) => a.name.localeCompare(b.name)) + .map((host) => ({ + ...host, + workspaceCount: counts.get(host.machineId) ?? 0, + })); + }, [hosts, workspaces]); + + return ( + + {hostRows.map((host, index) => ( + } + label={host.name} + subtitle={`${host.workspaceCount} ${host.workspaceCount === 1 ? "workspace" : "workspaces"}`} + 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)/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"; From 2f6b737285fac06a428d82486c98582810bdb36c Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Wed, 8 Jul 2026 17:19:46 -0700 Subject: [PATCH 13/30] fix(mobile): move new-chat widget sheets to native @expo/ui BottomSheet The gorhom modals never presented in-app (only ever exercised in Storybook); the org switcher's @expo/ui sheet pattern is proven on this screen. Context/target/branch pickers converted, plus a native model picker replacing the gorhom ModelSelector. --- .../NewChatWidget/NewChatWidget.tsx | 66 ++--- .../BranchPickerSheet/BranchPickerSheet.tsx | 276 ++++++++---------- .../components/ContextSheet/ContextSheet.tsx | 206 ++++++------- .../ModelPickerSheet/ModelPickerSheet.tsx | 88 ++++++ .../components/ModelPickerSheet/index.ts | 1 + .../TargetPickerSheet/TargetPickerSheet.tsx | 179 ++++++------ 6 files changed, 418 insertions(+), 398 deletions(-) create mode 100644 apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/ModelPickerSheet/ModelPickerSheet.tsx create mode 100644 apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/ModelPickerSheet/index.ts diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/NewChatWidget.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/NewChatWidget.tsx index 1b08c20a42e..a373c62fcb4 100644 --- a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/NewChatWidget.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/NewChatWidget.tsx @@ -1,4 +1,3 @@ -import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import { SUPERSET_CHAT_MODELS } from "@superset/shared/agent-models"; import { useQuery } from "@tanstack/react-query"; import { ChevronDownIcon, PlusIcon } from "lucide-react-native"; @@ -11,15 +10,6 @@ import { View, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { - ModelSelector, - ModelSelectorContent, - ModelSelectorItem, - ModelSelectorList, - ModelSelectorName, - ModelSelectorTrigger, - ModelSelectorValue, -} from "@/components/ai-elements/model-selector"; import { PromptInput, PromptInputAttachments, @@ -41,6 +31,7 @@ import { getHostServiceClientByUrl } from "@/lib/host-service/client"; import { ProjectAvatar } from "@/screens/(authenticated)/(home)/filter/components/ProjectAvatar"; import { BranchPickerSheet } from "./components/BranchPickerSheet"; import { ContextSheet } from "./components/ContextSheet"; +import { ModelPickerSheet } from "./components/ModelPickerSheet"; import { TargetPickerSheet } from "./components/TargetPickerSheet"; import { useCreateChatWorkspace } from "./hooks/useCreateChatWorkspace"; import { useNewChatTargets } from "./hooks/useNewChatTargets"; @@ -54,9 +45,7 @@ export function NewChatWidget({ return ( - - - + ); @@ -73,6 +62,7 @@ function NewChatWidgetInner({ const [targetSheetOpen, setTargetSheetOpen] = useState(false); const [branchSheetOpen, setBranchSheetOpen] = useState(false); const [contextSheetOpen, setContextSheetOpen] = useState(false); + const [modelSheetOpen, setModelSheetOpen] = useState(false); const [focused, setFocused] = useState(false); const [explicitTargetKey, setExplicitTargetKey] = useState( null, @@ -199,25 +189,19 @@ function NewChatWidgetInner({ > - - - - {selectedModel?.label} - - - - - {SUPERSET_CHAT_MODELS.map((model) => ( - - {model.label} - - {model.provider} - - - ))} - - - + setModelSheetOpen(true)} + > + + {selectedModel?.label ?? "Model"} + + + { @@ -239,16 +223,22 @@ function NewChatWidgetInner({ }} /> + ); diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/BranchPickerSheet/BranchPickerSheet.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/BranchPickerSheet/BranchPickerSheet.tsx index c89edd76457..b6efdf20fcd 100644 --- a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/BranchPickerSheet/BranchPickerSheet.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/BranchPickerSheet/BranchPickerSheet.tsx @@ -1,95 +1,71 @@ +import { BottomSheet, Group, Host, RNHostView } from "@expo/ui/swift-ui"; import { - BottomSheetBackdrop, - type BottomSheetBackdropProps, - BottomSheetModal, - BottomSheetScrollView, - BottomSheetTextInput, -} from "@gorhom/bottom-sheet"; + background, + environment, + presentationDragIndicator, +} from "@expo/ui/swift-ui/modifiers"; +import Ionicons from "@expo/vector-icons/Ionicons"; import { useQuery } from "@tanstack/react-query"; -import { CheckIcon, SearchIcon, XIcon } from "lucide-react-native"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Pressable, StyleSheet, View } from "react-native"; -import { Icon } from "@/components/ui/icon"; +import { useMemo, useState } from "react"; +import { Pressable, ScrollView, useWindowDimensions, 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 { THEME } from "@/lib/theme"; + +const LIST_MAX_HEIGHT = 320; function BranchRow({ name, isSelected, - isLast, onPress, }: { name: string; isSelected: boolean; - isLast?: boolean; onPress: () => void; }) { + const theme = useTheme(); return ( - - + + {name} {isSelected ? ( - + ) : null} ); } -function SectionLabel({ children }: { children: string }) { - return ( - - {children} - - ); -} - export function BranchPickerSheet({ - open, - onOpenChange, + isPresented, + onIsPresentedChange, hostUrl, projectId, selectedBranch, onSelect, }: { - open: boolean; - onOpenChange: (open: boolean) => void; + isPresented: boolean; + onIsPresentedChange: (value: boolean) => void; hostUrl: string | null; projectId: string | null; /** Null = default branch. */ selectedBranch: string | null; onSelect: (branch: string | null) => void; }) { - const modalRef = useRef(null); + const theme = useTheme(); + const { width } = useWindowDimensions(); const [query, setQuery] = useState(""); - useEffect(() => { - if (open) { - modalRef.current?.present(); - } else { - modalRef.current?.dismiss(); - } - }, [open]); - const trimmedQuery = query.trim(); const { data, isLoading } = useQuery({ queryKey: ["host-service", "branches", hostUrl, projectId, trimmedQuery], - enabled: open && hostUrl !== null && projectId !== null, + enabled: isPresented && hostUrl !== null && projectId !== null, placeholderData: (previous) => previous, networkMode: "always" as const, queryFn: async () => { @@ -111,117 +87,103 @@ export function BranchPickerSheet({ [data, defaultBranch], ); - const handleDismiss = useCallback(() => { - setQuery(""); - onOpenChange(false); - }, [onOpenChange]); + const handlePresentedChange = (value: boolean) => { + if (!value) setQuery(""); + onIsPresentedChange(value); + }; - const selectAndClose = useCallback( - (branch: string | null) => { - onSelect(branch); - onOpenChange(false); - }, - [onSelect, onOpenChange], - ); - - const renderBackdrop = useCallback( - (backdropProps: BottomSheetBackdropProps) => ( - - ), - [], - ); + const selectAndClose = (branch: string | null) => { + onSelect(branch); + handlePresentedChange(false); + }; return ( - - - - onOpenChange(false)} - > - - - - Branch - - - - - - - + + - {defaultBranch ? ( - <> - Default - selectAndClose(null)} - isLast - /> - - ) : null} - {branches.length > 0 ? ( - {trimmedQuery ? "Branches" : "Recents"} - ) : null} - {branches.map((branch, index) => ( - selectAndClose(branch.name)} - isLast={index === branches.length - 1} - /> - ))} - {isLoading && !data ? ( - - - - ) : null} - {!isLoading && !defaultBranch && branches.length === 0 ? ( - - - No branches found + + + + Branch + + + {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} + - ) : 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 index 99459b5ae48..338952a326c 100644 --- 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 @@ -1,21 +1,20 @@ -import Ionicons from "@expo/vector-icons/Ionicons"; +import { BottomSheet, Group, Host, RNHostView } from "@expo/ui/swift-ui"; import { - BottomSheetBackdrop, - type BottomSheetBackdropProps, - BottomSheetModal, - BottomSheetView, -} from "@gorhom/bottom-sheet"; + background, + environment, + 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, useEffect, useRef, useState } from "react"; -import { Alert, Pressable, View } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; +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 { THEME } from "@/lib/theme"; +import { useTheme } from "@/hooks/useTheme"; import { PhotoCarousel } from "./components/PhotoCarousel"; // Pickers present their own view controller; wait for the sheet's @@ -23,30 +22,25 @@ import { PhotoCarousel } from "./components/PhotoCarousel"; const SHEET_DISMISS_DELAY_MS = 400; export function ContextSheet({ - open, - onOpenChange, + isPresented, + onIsPresentedChange, }: { - open: boolean; - onOpenChange: (open: boolean) => void; + isPresented: boolean; + onIsPresentedChange: (value: boolean) => void; }) { - const insets = useSafeAreaInsets(); + const theme = useTheme(); + const { width } = useWindowDimensions(); const attachments = usePromptInputAttachments(); - const modalRef = useRef(null); const [selected, setSelected] = useState([]); const [adding, setAdding] = useState(false); - useEffect(() => { - if (open) { - modalRef.current?.present(); - } else { - modalRef.current?.dismiss(); - } - }, [open]); - - const handleDismiss = useCallback(() => { - setSelected([]); - onOpenChange(false); - }, [onOpenChange]); + const handlePresentedChange = useCallback( + (value: boolean) => { + if (!value) setSelected([]); + onIsPresentedChange(value); + }, + [onIsPresentedChange], + ); const toggleAsset = useCallback((asset: MediaLibrary.Asset) => { setSelected((previous) => @@ -57,7 +51,7 @@ export function ContextSheet({ }, []); const runAfterDismiss = (action: () => void) => { - onOpenChange(false); + handlePresentedChange(false); setTimeout(action, SHEET_DISMISS_DELAY_MS); }; @@ -102,8 +96,7 @@ export function ContextSheet({ }), ); attachments.add(items); - setSelected([]); - onOpenChange(false); + handlePresentedChange(false); } catch (error) { Alert.alert( "Could not add photos", @@ -132,85 +125,80 @@ export function ContextSheet({ }, ]; - const renderBackdrop = useCallback( - (backdropProps: BottomSheetBackdropProps) => ( - - ), - [], - ); - return ( - - - - onOpenChange(false)} - > - - - Context - - - - - Add - - {rows.map((row) => ( - runAfterDismiss(row.action)} - className="flex-row items-center gap-2.5 py-2.5" - > - - - {row.label} + + + + + + + Context - - ))} - - {selected.length > 0 ? ( - - - - ) : null} - - + + + + Add + + {rows.map((row) => ( + runAfterDismiss(row.action)} + className="flex-row items-center gap-2.5 py-2.5" + > + + + {row.label} + + + ))} + + {selected.length > 0 ? ( + + + + ) : null} + + + + + ); } diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/ModelPickerSheet/ModelPickerSheet.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/ModelPickerSheet/ModelPickerSheet.tsx new file mode 100644 index 00000000000..16182aed6c0 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/ModelPickerSheet/ModelPickerSheet.tsx @@ -0,0 +1,88 @@ +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 { SUPERSET_CHAT_MODELS } from "@superset/shared/agent-models"; +import { Pressable, useWindowDimensions, View } from "react-native"; +import { Text } from "@/components/ui/text"; +import { useTheme } from "@/hooks/useTheme"; + +export function ModelPickerSheet({ + isPresented, + onIsPresentedChange, + selectedModelId, + onSelect, +}: { + isPresented: boolean; + onIsPresentedChange: (value: boolean) => void; + selectedModelId: string; + onSelect: (modelId: string) => void; +}) { + const theme = useTheme(); + const { width } = useWindowDimensions(); + + return ( + + + + + + + Model + + {SUPERSET_CHAT_MODELS.map((model) => { + const isSelected = model.id === selectedModelId; + return ( + { + onSelect(model.id); + onIsPresentedChange(false); + }} + 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)/workspaces/components/NewChatWidget/components/ModelPickerSheet/index.ts b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/ModelPickerSheet/index.ts new file mode 100644 index 00000000000..c4a46af5e93 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/ModelPickerSheet/index.ts @@ -0,0 +1 @@ +export { ModelPickerSheet } from "./ModelPickerSheet"; diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/TargetPickerSheet/TargetPickerSheet.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/TargetPickerSheet/TargetPickerSheet.tsx index 429a71cd14c..a0e8e4a010b 100644 --- a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/TargetPickerSheet/TargetPickerSheet.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/TargetPickerSheet/TargetPickerSheet.tsx @@ -1,115 +1,106 @@ +import { BottomSheet, Group, Host, RNHostView } from "@expo/ui/swift-ui"; import { - BottomSheetBackdrop, - type BottomSheetBackdropProps, - BottomSheetModal, - BottomSheetScrollView, -} from "@gorhom/bottom-sheet"; -import { CheckIcon } from "lucide-react-native"; -import { useCallback, useEffect, useRef } from "react"; -import { Pressable, View } from "react-native"; -import { Icon } from "@/components/ui/icon"; + background, + environment, + presentationDragIndicator, +} from "@expo/ui/swift-ui/modifiers"; +import Ionicons from "@expo/vector-icons/Ionicons"; +import { Pressable, useWindowDimensions, View } from "react-native"; import { Text } from "@/components/ui/text"; -import { THEME } from "@/lib/theme"; +import { useTheme } from "@/hooks/useTheme"; import { ProjectAvatar } from "@/screens/(authenticated)/(home)/filter/components/ProjectAvatar"; import type { NewChatTarget } from "../../hooks/useNewChatTargets"; export function TargetPickerSheet({ - open, - onOpenChange, + isPresented, + onIsPresentedChange, targets, selectedKey, onSelect, }: { - open: boolean; - onOpenChange: (open: boolean) => void; + isPresented: boolean; + onIsPresentedChange: (value: boolean) => void; targets: NewChatTarget[]; selectedKey: string | null; onSelect: (target: NewChatTarget) => void; }) { - const modalRef = useRef(null); - - useEffect(() => { - if (open) { - modalRef.current?.present(); - } else { - modalRef.current?.dismiss(); - } - }, [open]); - - const renderBackdrop = useCallback( - (backdropProps: BottomSheetBackdropProps) => ( - - ), - [], - ); + const theme = useTheme(); + const { width } = useWindowDimensions(); return ( - onOpenChange(false)} - ref={modalRef} - snapPoints={["50%"]} - > - + - - Projects - - {targets.length === 0 ? ( - - - No projects on an online host - - - ) : null} - {targets.map((target) => { - const isSelected = target.key === selectedKey; - return ( - { - onSelect(target); - onOpenChange(false); - }} - > - - - - {target.projectName} - - - {target.hostName} + + + + + Projects + + {targets.length === 0 ? ( + + No projects on an online host - - {isSelected ? ( - ) : null} - - ); - })} - - + {targets.map((target) => { + const isSelected = target.key === selectedKey; + return ( + { + onSelect(target); + onIsPresentedChange(false); + }} + className="flex-row items-center gap-2.5 py-2.5" + > + + + + {target.projectName} + + + {target.hostName} + + + {isSelected ? ( + + ) : null} + + ); + })} + + + + + ); } From 3110524c6d337378838fe71a5f613ea8c7c5d62e Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Wed, 8 Jul 2026 17:27:40 -0700 Subject: [PATCH 14/30] feat(mobile): branch picker opens as a full-height sheet --- .../BranchPickerSheet/BranchPickerSheet.tsx | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/BranchPickerSheet/BranchPickerSheet.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/BranchPickerSheet/BranchPickerSheet.tsx index b6efdf20fcd..35fa06a92d6 100644 --- a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/BranchPickerSheet/BranchPickerSheet.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/BranchPickerSheet/BranchPickerSheet.tsx @@ -2,6 +2,7 @@ import { BottomSheet, Group, Host, RNHostView } from "@expo/ui/swift-ui"; import { background, environment, + presentationDetents, presentationDragIndicator, } from "@expo/ui/swift-ui/modifiers"; import Ionicons from "@expo/vector-icons/Ionicons"; @@ -14,7 +15,10 @@ import { Text } from "@/components/ui/text"; import { useTheme } from "@/hooks/useTheme"; import { getHostServiceClientByUrl } from "@/lib/host-service/client"; -const LIST_MAX_HEIGHT = 320; +// RNHostView sizes to its RN content, so the full-height sheet needs an +// explicit content height; the large detent is roughly the screen minus +// the top inset. +const LARGE_DETENT_FRACTION = 0.88; function BranchRow({ name, @@ -59,7 +63,7 @@ export function BranchPickerSheet({ onSelect: (branch: string | null) => void; }) { const theme = useTheme(); - const { width } = useWindowDimensions(); + const { width, height } = useWindowDimensions(); const [query, setQuery] = useState(""); const trimmedQuery = query.trim(); @@ -102,17 +106,20 @@ export function BranchPickerSheet({ - + From 2d4d4ac87956973d1e8d20e1ffe9e512c293a1da Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Wed, 8 Jul 2026 17:32:32 -0700 Subject: [PATCH 15/30] feat(mobile): context sheet medium/large detents; Add button copy for single selection --- .../components/ContextSheet/ContextSheet.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) 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 index 338952a326c..c47de26fd0a 100644 --- 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 @@ -2,6 +2,8 @@ import { BottomSheet, Group, Host, RNHostView } from "@expo/ui/swift-ui"; import { background, environment, + frame, + presentationDetents, presentationDragIndicator, } from "@expo/ui/swift-ui/modifiers"; import Ionicons from "@expo/vector-icons/Ionicons"; @@ -130,13 +132,14 @@ export function ContextSheet({ @@ -190,7 +193,11 @@ export function ContextSheet({ {adding ? ( ) : ( - {`Add ${selected.length}`} + + {selected.length === 1 + ? "Add" + : `Add ${selected.length}`} + )} From 1931f0b8a8196d9e8d7ae1f7bd01e5c09a89707c Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Wed, 8 Jul 2026 17:33:34 -0700 Subject: [PATCH 16/30] feat(mobile): single-host workspace view; drop Electric v2_workspaces fallback - the list is always scoped to one resolved host (explicit pick, else first online, else first); offline selected host renders a full-screen placeholder with a Switch host CTA that opens the filter's host page - host filter requires a specific host (All hosts removed); project workspace counts scoped to the selected host - useHostWorkspaces drops the cloud-fallback merge and the v2_workspaces Electric collection entirely (R3-ready); merge is now concat + dedupe - host lists (settings, filter) no longer show workspace counts, removing their dependency on the workspace fan-out --- .../useHostWorkspaces/useHostWorkspaces.ts | 22 ++--- .../useHostWorkspaces.utils.ts | 45 ++--------- apps/mobile/lib/collections/collections.ts | 18 ----- .../(home)/filter/FilterScreen.tsx | 12 +-- .../(home)/filter/host/HostFilterScreen.tsx | 62 +++----------- .../filter/project/ProjectFilterScreen.tsx | 5 +- .../(home)/hooks/useSelectedHost/index.ts | 1 + .../hooks/useSelectedHost/useSelectedHost.ts | 31 +++++++ .../(home)/workspaces/WorkspacesScreen.tsx | 80 +++++++++++-------- .../HostOfflineView/HostOfflineView.tsx | 31 +++++++ .../components/HostOfflineView/index.ts | 1 + .../components/WorkspaceRow/WorkspaceRow.tsx | 14 ++-- .../useVisibleDiffStats.ts | 1 - .../settings/hosts/HostsSettingsScreen.tsx | 19 +---- 14 files changed, 152 insertions(+), 190 deletions(-) create mode 100644 apps/mobile/screens/(authenticated)/(home)/hooks/useSelectedHost/index.ts create mode 100644 apps/mobile/screens/(authenticated)/(home)/hooks/useSelectedHost/useSelectedHost.ts create mode 100644 apps/mobile/screens/(authenticated)/(home)/workspaces/components/HostOfflineView/HostOfflineView.tsx create mode 100644 apps/mobile/screens/(authenticated)/(home)/workspaces/components/HostOfflineView/index.ts diff --git a/apps/mobile/hooks/useHostWorkspaces/useHostWorkspaces.ts b/apps/mobile/hooks/useHostWorkspaces/useHostWorkspaces.ts index 401eb7c4980..eb58cfa24fe 100644 --- a/apps/mobile/hooks/useHostWorkspaces/useHostWorkspaces.ts +++ b/apps/mobile/hooks/useHostWorkspaces/useHostWorkspaces.ts @@ -38,10 +38,10 @@ export interface UseHostWorkspacesResult { /** * The workspace read path (mobile port of desktop's useHostWorkspaces): - * fan out `workspace.list` to every online host via the relay, merge, and - * fall back to the Electric cloud collection for hosts that served - * nothing. No local host, no event bus — the 30s poll plus focus/pull - * refetch is the healing path. + * fan out `workspace.list` to every online host via the relay and merge. + * No local host, no event bus — the 30s poll plus focus/pull refetch is + * the healing path. Offline hosts serve nothing; the UI shows them as a + * placeholder. */ export function useHostWorkspaces(): UseHostWorkspacesResult { const collections = useCollections(); @@ -51,10 +51,6 @@ export function useHostWorkspaces(): UseHostWorkspacesResult { (q) => q.from({ v2Hosts: collections.v2Hosts }), [collections], ); - const { data: cloudRows } = useLiveQuery( - (q) => q.from({ v2Workspaces: collections.v2Workspaces }), - [collections], - ); const targets = useMemo( () => deriveHostWorkspacesQueryTargets(hosts ?? []), @@ -77,18 +73,16 @@ export function useHostWorkspaces(): UseHostWorkspacesResult { const workspaces = useMemo( () => - mergeHostWorkspaces({ - hostResults: targets.map((target, index) => { + mergeHostWorkspaces( + targets.map((_target, index) => { const query = queries[index]; return { - target, rows: query?.data, reachable: query?.data !== undefined && !query?.isError, }; }), - cloudRows: cloudRows ?? [], - }), - [targets, queries, cloudRows], + ), + [targets, queries], ); const isReady = queries.every( diff --git a/apps/mobile/hooks/useHostWorkspaces/useHostWorkspaces.utils.ts b/apps/mobile/hooks/useHostWorkspaces/useHostWorkspaces.utils.ts index b9bb70bf9ab..97abe61a66f 100644 --- a/apps/mobile/hooks/useHostWorkspaces/useHostWorkspaces.utils.ts +++ b/apps/mobile/hooks/useHostWorkspaces/useHostWorkspaces.utils.ts @@ -1,4 +1,3 @@ -import type { SelectV2Workspace } from "@superset/db/schema"; import { buildRelayHostUrl, type HostWorkspaceRow, @@ -6,13 +5,9 @@ import { export type { HostWorkspaceRow } from "@/lib/host-service/client"; -export interface HostWorkspaceItem extends SelectV2Workspace { - worktreePath?: string; - worktreeExists?: boolean; - /** False when the row came from the cloud fallback and the host didn't answer. */ +export interface HostWorkspaceItem extends HostWorkspaceRow { + /** False when the row is a cached result and the host stopped answering. */ hostReachable: boolean; - /** "host" = served by a host; "cloud" = Electric fallback. */ - source: "host" | "cloud"; } export interface HostWorkspacesQueryTarget { @@ -52,51 +47,23 @@ export function deriveHostWorkspacesQueryTargets( })); } -/** - * Merge per-host results with the Electric fallback. A host that answered - * is authoritative for its rows — cloud rows for that host are ignored (a - * deleted row must not resurrect). Cloud rows only fill in for hosts with - * no host-served data. The fallback is deleted in R3 along with the cloud - * table. - */ -export function mergeHostWorkspaces({ - hostResults, - cloudRows, -}: { +export function mergeHostWorkspaces( hostResults: Array<{ - target: HostWorkspacesQueryTarget; rows: HostWorkspaceRow[] | undefined; reachable: boolean; - }>; - cloudRows: SelectV2Workspace[]; -}): HostWorkspaceItem[] { + }>, +): HostWorkspaceItem[] { const items: HostWorkspaceItem[] = []; - const hostsWithData = new Set(); const seenIds = new Set(); for (const result of hostResults) { if (!result.rows) continue; - hostsWithData.add(result.target.machineId); for (const row of result.rows) { if (seenIds.has(row.id)) continue; seenIds.add(row.id); - items.push({ - ...row, - hostReachable: result.reachable, - source: "host", - }); + items.push({ ...row, hostReachable: result.reachable }); } } - for (const row of cloudRows) { - if (seenIds.has(row.id) || hostsWithData.has(row.hostId)) continue; - seenIds.add(row.id); - items.push({ - ...row, - hostReachable: false, - source: "cloud", - }); - } - return items; } diff --git a/apps/mobile/lib/collections/collections.ts b/apps/mobile/lib/collections/collections.ts index f6a45935a07..6ee00edc92b 100644 --- a/apps/mobile/lib/collections/collections.ts +++ b/apps/mobile/lib/collections/collections.ts @@ -16,7 +16,6 @@ import type { SelectUser, SelectV2Host, SelectV2Project, - SelectV2Workspace, } from "@superset/db/schema"; import { electricCollectionOptions } from "@tanstack/electric-db-collection"; import type { Collection } from "@tanstack/react-db"; @@ -68,7 +67,6 @@ interface OrgCollections { users: Collection; invitations: Collection; v2Projects: Collection; - v2Workspaces: Collection; v2Hosts: Collection; subscriptions: Collection; chatSessions: Collection; @@ -211,21 +209,6 @@ function createOrgCollections(organizationId: string): OrgCollections { }), ); - const v2Workspaces = createCollection( - electricCollectionOptions({ - id: `v2-workspaces-${organizationId}`, - shapeOptions: { - url: electricUrl, - params: { table: "v2_workspaces", organizationId }, - headers: electricHeaders, - columnMapper, - parser, - onError: handleElectricSyncError, - }, - getKey: (item) => item.id, - }), - ); - const v2Hosts = createCollection( electricCollectionOptions({ id: `v2-hosts-${organizationId}`, @@ -294,7 +277,6 @@ function createOrgCollections(organizationId: string): OrgCollections { users, invitations, v2Projects, - v2Workspaces, v2Hosts, subscriptions, chatSessions, diff --git a/apps/mobile/screens/(authenticated)/(home)/filter/FilterScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/filter/FilterScreen.tsx index 1f12660d29b..65de61f262c 100644 --- a/apps/mobile/screens/(authenticated)/(home)/filter/FilterScreen.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/filter/FilterScreen.tsx @@ -3,6 +3,7 @@ import { useLiveQuery } from "@tanstack/react-db"; import { 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, @@ -20,17 +21,13 @@ export function FilterScreen() { const projectFilter = useWorkspacesFilterStore( (store) => store.projectFilter, ); - const hostFilter = useWorkspacesFilterStore((store) => store.hostFilter); + const selectedHost = useSelectedHost(); const sort = useWorkspacesFilterStore((store) => store.sort); const { data: projects } = useLiveQuery( (q) => q.from({ v2Projects: collections.v2Projects }), [collections], ); - const { data: hosts } = useLiveQuery( - (q) => q.from({ v2Hosts: collections.v2Hosts }), - [collections], - ); const sortedProjects = [...(projects ?? [])].sort((a, b) => a.name.localeCompare(b.name), @@ -38,9 +35,6 @@ export function FilterScreen() { const selectedProject = sortedProjects.find((project) => project.id === projectFilter) ?? sortedProjects[0]; - const selectedHost = (hosts ?? []).find( - (host) => host.machineId === hostFilter, - ); const sortLabel = SORT_OPTIONS.find((option) => option.value === sort)?.label ?? ""; @@ -82,7 +76,7 @@ export function FilterScreen() { label="Host" trailing={ diff --git a/apps/mobile/screens/(authenticated)/(home)/filter/host/HostFilterScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/filter/host/HostFilterScreen.tsx index 50833ea3495..047052ecd71 100644 --- a/apps/mobile/screens/(authenticated)/(home)/filter/host/HostFilterScreen.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/filter/host/HostFilterScreen.tsx @@ -1,9 +1,8 @@ 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 { 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"; @@ -12,51 +11,23 @@ import { useCollections } from "@/screens/(authenticated)/providers/CollectionsP export function HostFilterScreen() { const router = useRouter(); - const theme = useTheme(); const collections = useCollections(); - const { workspaces } = useHostWorkspaces(); - const hostFilter = useWorkspacesFilterStore((store) => store.hostFilter); + const selectedHost = useSelectedHost(); const setHostFilter = useWorkspacesFilterStore( (store) => store.setHostFilter, ); - const projectFilter = useWorkspacesFilterStore( - (store) => store.projectFilter, - ); const { data: hosts } = useLiveQuery( (q) => q.from({ v2Hosts: collections.v2Hosts }), [collections], ); - const { data: projects } = useLiveQuery( - (q) => q.from({ v2Projects: collections.v2Projects }), - [collections], - ); - const selectedProjectId = useMemo(() => { - if (projectFilter) return projectFilter; - const sorted = [...(projects ?? [])].sort((a, b) => - a.name.localeCompare(b.name), - ); - return sorted[0]?.id ?? null; - }, [projects, projectFilter]); - - const sortedHosts = useMemo(() => { - const counts = new Map(); - for (const workspace of workspaces) { - if (workspace.projectId !== selectedProjectId) continue; - counts.set(workspace.hostId, (counts.get(workspace.hostId) ?? 0) + 1); - } - return (hosts ?? []) - .map((host) => ({ - machineId: host.machineId, - name: host.name, - isOnline: host.isOnline, - workspaceCount: counts.get(host.machineId) ?? 0, - })) - .sort((a, b) => a.name.localeCompare(b.name)); - }, [hosts, workspaces, selectedProjectId]); + const sortedHosts = useMemo( + () => [...(hosts ?? [])].sort((a, b) => a.name.localeCompare(b.name)), + [hosts], + ); - const selectHost = (machineId: string | null) => { + const selectHost = (machineId: string) => { setHostFilter(machineId); router.back(); }; @@ -66,26 +37,15 @@ export function HostFilterScreen() { className="bg-background flex-1" contentContainerClassName="px-6 pb-10" > - } - onPress={() => selectHost(null)} - /> {sortedHosts.map((host, index) => ( } label={host.name} trailing={ - <> - - {host.workspaceCount} - - - + } onPress={() => selectHost(host.machineId)} isLast={index === sortedHosts.length - 1} diff --git a/apps/mobile/screens/(authenticated)/(home)/filter/project/ProjectFilterScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/filter/project/ProjectFilterScreen.tsx index 01386a24990..ae838cb9e58 100644 --- a/apps/mobile/screens/(authenticated)/(home)/filter/project/ProjectFilterScreen.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/filter/project/ProjectFilterScreen.tsx @@ -4,6 +4,7 @@ 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"; @@ -15,6 +16,7 @@ export function ProjectFilterScreen() { const theme = useTheme(); const collections = useCollections(); const { workspaces } = useHostWorkspaces(); + const selectedHost = useSelectedHost(); const projectFilter = useWorkspacesFilterStore( (store) => store.projectFilter, ); @@ -35,13 +37,14 @@ export function ProjectFilterScreen() { const workspaceCounts = useMemo(() => { const counts = new Map(); for (const workspace of workspaces) { + if (workspace.hostId !== selectedHost?.machineId) continue; counts.set( workspace.projectId, (counts.get(workspace.projectId) ?? 0) + 1, ); } return counts; - }, [workspaces]); + }, [workspaces, selectedHost]); const selectedProjectId = projectFilter ?? sortedProjects[0]?.id ?? null; 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)/workspaces/WorkspacesScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/WorkspacesScreen.tsx index 18b6b633bcc..f3876eb60a5 100644 --- a/apps/mobile/screens/(authenticated)/(home)/workspaces/WorkspacesScreen.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/WorkspacesScreen.tsx @@ -13,8 +13,10 @@ import { 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"; @@ -37,7 +39,6 @@ export function WorkspacesScreen() { const projectFilter = useWorkspacesFilterStore( (store) => store.projectFilter, ); - const hostFilter = useWorkspacesFilterStore((store) => store.hostFilter); const sort = useWorkspacesFilterStore((store) => store.sort); const [searchQuery, setSearchQuery] = useState(""); const [visibleIds, setVisibleIds] = useState([]); @@ -54,6 +55,7 @@ export function WorkspacesScreen() { } = useOrganizations(); const { workspaces, isReady, cache } = useHostWorkspaces(); + const selectedHost = useSelectedHost(); const { data: projects } = useLiveQuery( (q) => q.from({ v2Projects: collections.v2Projects }), @@ -91,13 +93,13 @@ export function WorkspacesScreen() { : workspaces.filter( (workspace) => workspace.projectId === selectedProjectId && - (hostFilter === null || workspace.hostId === hostFilter), + workspace.hostId === selectedHost?.machineId, ); return matches.sort((a, b) => compareDesc(a[sort], b[sort])); }, [ workspaces, selectedProjectId, - hostFilter, + selectedHost, sort, searchQuery, projectNamesById, @@ -228,36 +230,48 @@ export function WorkspacesScreen() { onPress={() => router.push("/(authenticated)/(home)/filter")} /> - 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 - } - /> + {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 + } + /> + )} + + + {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/WorkspaceRow/WorkspaceRow.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/WorkspaceRow/WorkspaceRow.tsx index 55cc93923f0..aefa4398297 100644 --- a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/WorkspaceRow/WorkspaceRow.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/WorkspaceRow/WorkspaceRow.tsx @@ -97,15 +97,11 @@ export function WorkspaceRow({ ]} /> - {workspace.source === "cloud" ? ( - - ) : ( - - )} + diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/hooks/useVisibleDiffStats/useVisibleDiffStats.ts b/apps/mobile/screens/(authenticated)/(home)/workspaces/hooks/useVisibleDiffStats/useVisibleDiffStats.ts index 977452c61ca..567af88a8e0 100644 --- a/apps/mobile/screens/(authenticated)/(home)/workspaces/hooks/useVisibleDiffStats/useVisibleDiffStats.ts +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/hooks/useVisibleDiffStats/useVisibleDiffStats.ts @@ -55,7 +55,6 @@ export function useVisibleDiffStats({ const workspace = workspacesById.get(workspaceId); if ( !workspace || - workspace.source !== "host" || !workspace.hostReachable || workspace.worktreeExists === false ) { diff --git a/apps/mobile/screens/(authenticated)/settings/hosts/HostsSettingsScreen.tsx b/apps/mobile/screens/(authenticated)/settings/hosts/HostsSettingsScreen.tsx index 1282b45b6a7..a8dbda42dfe 100644 --- a/apps/mobile/screens/(authenticated)/settings/hosts/HostsSettingsScreen.tsx +++ b/apps/mobile/screens/(authenticated)/settings/hosts/HostsSettingsScreen.tsx @@ -2,7 +2,6 @@ import { useLiveQuery } from "@tanstack/react-db"; import { formatDistanceToNow } from "date-fns"; import { useMemo } from "react"; import { ScrollView, Text } from "react-native"; -import { useHostWorkspaces } from "@/hooks/useHostWorkspaces"; import { useTheme } from "@/hooks/useTheme"; import { HostStatusDot } from "@/screens/(authenticated)/components/HostStatusDot"; import { ListRow } from "@/screens/(authenticated)/components/ListRow"; @@ -11,25 +10,16 @@ import { useCollections } from "@/screens/(authenticated)/providers/CollectionsP export function HostsSettingsScreen() { const theme = useTheme(); const collections = useCollections(); - const { workspaces } = useHostWorkspaces(); const { data: hosts } = useLiveQuery( (q) => q.from({ v2Hosts: collections.v2Hosts }), [collections], ); - const hostRows = useMemo(() => { - const counts = new Map(); - for (const workspace of workspaces) { - counts.set(workspace.hostId, (counts.get(workspace.hostId) ?? 0) + 1); - } - return [...(hosts ?? [])] - .sort((a, b) => a.name.localeCompare(b.name)) - .map((host) => ({ - ...host, - workspaceCount: counts.get(host.machineId) ?? 0, - })); - }, [hosts, workspaces]); + const hostRows = useMemo( + () => [...(hosts ?? [])].sort((a, b) => a.name.localeCompare(b.name)), + [hosts], + ); return ( } label={host.name} - subtitle={`${host.workspaceCount} ${host.workspaceCount === 1 ? "workspace" : "workspaces"}`} trailing={ {host.isOnline From 0b6a57358790e1a02bec111872b9eb4355e03231 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Wed, 8 Jul 2026 17:35:29 -0700 Subject: [PATCH 17/30] fix(mobile): paint sheet chrome with presentationBackground to match content The SwiftUI sheet's system-material base showed around our content's background; presentationBackground(theme background as hex) fills the whole sheet. Applied to all five bottom sheets. --- .../BranchPickerSheet/BranchPickerSheet.tsx | 3 +++ .../components/ContextSheet/ContextSheet.tsx | 3 +++ .../ModelPickerSheet/ModelPickerSheet.tsx | 3 +++ .../TargetPickerSheet/TargetPickerSheet.tsx | 3 +++ .../OrganizationSwitcherSheet.tsx | 3 +++ .../workspaces/utils/hslToHex/hslToHex.ts | 23 +++++++++++++++++++ .../(home)/workspaces/utils/hslToHex/index.ts | 1 + 7 files changed, 39 insertions(+) create mode 100644 apps/mobile/screens/(authenticated)/(home)/workspaces/utils/hslToHex/hslToHex.ts create mode 100644 apps/mobile/screens/(authenticated)/(home)/workspaces/utils/hslToHex/index.ts diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/BranchPickerSheet/BranchPickerSheet.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/BranchPickerSheet/BranchPickerSheet.tsx index 35fa06a92d6..23b1e9f4f01 100644 --- a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/BranchPickerSheet/BranchPickerSheet.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/BranchPickerSheet/BranchPickerSheet.tsx @@ -2,6 +2,7 @@ import { BottomSheet, Group, Host, RNHostView } from "@expo/ui/swift-ui"; import { background, environment, + presentationBackground, presentationDetents, presentationDragIndicator, } from "@expo/ui/swift-ui/modifiers"; @@ -14,6 +15,7 @@ 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 { hslToHex } from "../../../../utils/hslToHex"; // RNHostView sizes to its RN content, so the full-height sheet needs an // explicit content height; the large detent is roughly the screen minus @@ -113,6 +115,7 @@ export function BranchPickerSheet({ presentationDetents(["large"]), presentationDragIndicator("visible"), background(theme.background), + presentationBackground(hslToHex(theme.background)), ]} > 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 index c47de26fd0a..7b251707a3d 100644 --- 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 @@ -3,6 +3,7 @@ import { background, environment, frame, + presentationBackground, presentationDetents, presentationDragIndicator, } from "@expo/ui/swift-ui/modifiers"; @@ -17,6 +18,7 @@ import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; import { Text } from "@/components/ui/text"; import { useTheme } from "@/hooks/useTheme"; +import { hslToHex } from "../../../../utils/hslToHex"; import { PhotoCarousel } from "./components/PhotoCarousel"; // Pickers present their own view controller; wait for the sheet's @@ -139,6 +141,7 @@ export function ContextSheet({ presentationDetents(["medium", "large"]), presentationDragIndicator("visible"), background(theme.background), + presentationBackground(hslToHex(theme.background)), frame({ maxHeight: 10_000, alignment: "top" }), ]} > diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/ModelPickerSheet/ModelPickerSheet.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/ModelPickerSheet/ModelPickerSheet.tsx index 16182aed6c0..ade9668c522 100644 --- a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/ModelPickerSheet/ModelPickerSheet.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/ModelPickerSheet/ModelPickerSheet.tsx @@ -2,6 +2,7 @@ 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"; @@ -9,6 +10,7 @@ import { SUPERSET_CHAT_MODELS } from "@superset/shared/agent-models"; import { Pressable, useWindowDimensions, View } from "react-native"; import { Text } from "@/components/ui/text"; import { useTheme } from "@/hooks/useTheme"; +import { hslToHex } from "../../../../utils/hslToHex"; export function ModelPickerSheet({ isPresented, @@ -36,6 +38,7 @@ export function ModelPickerSheet({ environment("colorScheme", "dark"), presentationDragIndicator("visible"), background(theme.background), + presentationBackground(hslToHex(theme.background)), ]} > diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/TargetPickerSheet/TargetPickerSheet.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/TargetPickerSheet/TargetPickerSheet.tsx index a0e8e4a010b..290abbde954 100644 --- a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/TargetPickerSheet/TargetPickerSheet.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/TargetPickerSheet/TargetPickerSheet.tsx @@ -2,6 +2,7 @@ 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"; @@ -9,6 +10,7 @@ import { Pressable, useWindowDimensions, 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 { hslToHex } from "../../../../utils/hslToHex"; import type { NewChatTarget } from "../../hooks/useNewChatTargets"; export function TargetPickerSheet({ @@ -39,6 +41,7 @@ export function TargetPickerSheet({ environment("colorScheme", "dark"), presentationDragIndicator("visible"), background(theme.background), + presentationBackground(hslToHex(theme.background)), ]} > diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationSwitcherSheet/OrganizationSwitcherSheet.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationSwitcherSheet/OrganizationSwitcherSheet.tsx index b704e86ab56..4f5c7fa98ec 100644 --- a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationSwitcherSheet/OrganizationSwitcherSheet.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationSwitcherSheet/OrganizationSwitcherSheet.tsx @@ -2,6 +2,7 @@ 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"; @@ -11,6 +12,7 @@ import { Text } from "@/components/ui/text"; import { useSignOut } from "@/hooks/useSignOut"; import { useTheme } from "@/hooks/useTheme"; import { OrganizationAvatar } from "@/screens/(authenticated)/components/OrganizationAvatar"; +import { hslToHex } from "../../utils/hslToHex"; export interface Organization { id: string; @@ -60,6 +62,7 @@ export function OrganizationSwitcherSheet({ environment("colorScheme", "dark"), presentationDragIndicator("visible"), background(theme.background), + presentationBackground(hslToHex(theme.background)), ]} > 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"; From 3f30c1ec3cdb4a4b790d5e19bcbdb9fd61e5d654 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Wed, 8 Jul 2026 17:40:56 -0700 Subject: [PATCH 18/30] feat(mobile): screenshots picker in the context sheet Screenshots row navigates in-sheet (detent jumps to large) to a grid of the iOS Screenshots smart album with the same numbered multi-select and Add flow as the recents carousel. --- .../components/ContextSheet/ContextSheet.tsx | 184 ++++++++++++------ .../ScreenshotGrid/ScreenshotGrid.tsx | 133 +++++++++++++ .../components/ScreenshotGrid/index.ts | 1 + 3 files changed, 261 insertions(+), 57 deletions(-) create mode 100644 apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/ContextSheet/components/ScreenshotGrid/ScreenshotGrid.tsx create mode 100644 apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/ContextSheet/components/ScreenshotGrid/index.ts 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 index 7b251707a3d..128fd707950 100644 --- 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 @@ -20,10 +20,15 @@ import { Text } from "@/components/ui/text"; import { useTheme } from "@/hooks/useTheme"; 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, @@ -33,14 +38,20 @@ export function ContextSheet({ onIsPresentedChange: (value: boolean) => void; }) { const theme = useTheme(); - const { width } = useWindowDimensions(); + 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([]); + if (!value) { + setSelected([]); + setView("main"); + setDetent("medium"); + } onIsPresentedChange(value); }, [onIsPresentedChange], @@ -59,6 +70,11 @@ export function ContextSheet({ setTimeout(action, SHEET_DISMISS_DELAY_MS); }; + const openScreenshots = () => { + setView("screenshots"); + setDetent("large"); + }; + const openCamera = async () => { const permission = await ImagePicker.requestCameraPermissionsAsync(); if (!permission.granted) { @@ -111,24 +127,50 @@ export function ContextSheet({ } }; - const rows = [ + const mainRows = [ { icon: "images-outline" as const, label: "Photos", - action: () => void attachments.openImagePicker(), + onPress: () => runAfterDismiss(() => void attachments.openImagePicker()), + }, + { + icon: "scan-outline" as const, + label: "Screenshots", + onPress: openScreenshots, + showsChevron: true, }, { icon: "camera-outline" as const, label: "Camera", - action: () => void openCamera(), + onPress: () => runAfterDismiss(() => void openCamera()), }, { icon: "document-outline" as const, label: "Files", - action: () => void attachments.openFilePicker(), + 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)), @@ -146,66 +195,87 @@ export function ContextSheet({ ]} > - - - Context - - - - - Add - - {rows.map((row) => ( + runAfterDismiss(row.action)} - className="flex-row items-center gap-2.5 py-2.5" + accessibilityLabel="Back" + className="absolute left-4 size-9 items-center justify-center rounded-full bg-secondary" + onPress={() => setView("main")} > - - {row.label} - - ))} + + Screenshots + + + + {addButton} - {selected.length > 0 ? ( - - + {row.showsChevron ? ( + + ) : null} + + ))} - ) : null} - + {addButton} + + )} 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"; From 9fa74454cd5eb7b2fb9419644c7391a6fcac5a93 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Wed, 8 Jul 2026 17:48:31 -0700 Subject: [PATCH 19/30] feat(mobile): close buttons on all pickers/sheets; search icon in branch filter --- .../SheetCloseButton/SheetCloseButton.tsx | 27 +++++++++++ .../components/SheetCloseButton/index.ts | 1 + .../(home)/filter/FilterScreen.tsx | 13 ++++++ .../BranchPickerSheet/BranchPickerSheet.tsx | 45 +++++++++++++------ .../components/ContextSheet/ContextSheet.tsx | 20 ++++++--- .../ModelPickerSheet/ModelPickerSheet.tsx | 22 ++++++--- .../TargetPickerSheet/TargetPickerSheet.tsx | 22 ++++++--- 7 files changed, 116 insertions(+), 34 deletions(-) create mode 100644 apps/mobile/screens/(authenticated)/(home)/components/SheetCloseButton/SheetCloseButton.tsx create mode 100644 apps/mobile/screens/(authenticated)/(home)/components/SheetCloseButton/index.ts 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..18c20227784 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/components/SheetCloseButton/SheetCloseButton.tsx @@ -0,0 +1,27 @@ +import Ionicons from "@expo/vector-icons/Ionicons"; +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(); + return ( + + + + ); +} 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 index 65de61f262c..1a45f2522a1 100644 --- a/apps/mobile/screens/(authenticated)/(home)/filter/FilterScreen.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/filter/FilterScreen.tsx @@ -2,7 +2,9 @@ import Ionicons from "@expo/vector-icons/Ionicons"; import { useLiveQuery } from "@tanstack/react-db"; import { useRouter } from "expo-router"; import { View } from "react-native"; +import { Text } from "@/components/ui/text"; import { useTheme } from "@/hooks/useTheme"; +import { SheetCloseButton } from "@/screens/(authenticated)/(home)/components/SheetCloseButton"; import { useSelectedHost } from "@/screens/(authenticated)/(home)/hooks/useSelectedHost"; import { SORT_OPTIONS, @@ -40,6 +42,17 @@ export function FilterScreen() { return ( + + + router.back()} /> + + + Filter + + - - Branch - - + + + handlePresentedChange(false)} + /> + + + Branch + + + + + + + + ) : ( - - Context - + + + handlePresentedChange(false)} + /> + + + Context + + - - - Model - + + + + onIsPresentedChange(false)} + /> + + + Model + + {SUPERSET_CHAT_MODELS.map((model) => { const isSelected = model.id === selectedModelId; return ( diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/TargetPickerSheet/TargetPickerSheet.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/TargetPickerSheet/TargetPickerSheet.tsx index 290abbde954..7d7e24dd87e 100644 --- a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/TargetPickerSheet/TargetPickerSheet.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/TargetPickerSheet/TargetPickerSheet.tsx @@ -9,6 +9,7 @@ import Ionicons from "@expo/vector-icons/Ionicons"; import { Pressable, useWindowDimensions, View } from "react-native"; import { Text } from "@/components/ui/text"; import { useTheme } from "@/hooks/useTheme"; +import { SheetCloseButton } from "@/screens/(authenticated)/(home)/components/SheetCloseButton"; import { ProjectAvatar } from "@/screens/(authenticated)/(home)/filter/components/ProjectAvatar"; import { hslToHex } from "../../../../utils/hslToHex"; import type { NewChatTarget } from "../../hooks/useNewChatTargets"; @@ -45,13 +46,20 @@ export function TargetPickerSheet({ ]} > - - - Projects - + + + + onIsPresentedChange(false)} + /> + + + Project + + {targets.length === 0 ? ( Date: Wed, 8 Jul 2026 17:57:35 -0700 Subject: [PATCH 20/30] feat(mobile): move new-chat pickers to native formSheet routes Branch/model/project pickers are now expo-router formSheet routes with native headers and a headerLeft close button (same for the filter sheet), mirroring the filter sheet's nested-stack structure. Selection state moved into newChatPreferencesStore (baseBranch unpersisted via partialize) so the routes and the widget share it. The @expo/ui picker sheets are deleted; the context sheet stays in-tree for the attachments context. --- .../app/(authenticated)/(home)/_layout.tsx | 9 + .../(authenticated)/(home)/filter/_layout.tsx | 9 +- .../(home)/new-chat/_layout.tsx | 18 ++ .../(home)/new-chat/branch.tsx | 3 + .../(authenticated)/(home)/new-chat/model.tsx | 3 + .../(home)/new-chat/project.tsx | 3 + .../SheetCloseButton/SheetCloseButton.tsx | 6 +- .../(home)/filter/FilterScreen.tsx | 13 -- .../new-chat/branch/BranchPickerScreen.tsx | 155 +++++++++++++ .../(home)/new-chat/branch/index.ts | 1 + .../new-chat/model/ModelPickerScreen.tsx | 52 +++++ .../(home)/new-chat/model/index.ts | 1 + .../new-chat/project/ProjectPickerScreen.tsx | 81 +++++++ .../(home)/new-chat/project/index.ts | 1 + .../NewChatWidget/NewChatWidget.tsx | 58 ++--- .../BranchPickerSheet/BranchPickerSheet.tsx | 216 ------------------ .../components/BranchPickerSheet/index.ts | 1 - .../ModelPickerSheet/ModelPickerSheet.tsx | 99 -------- .../components/ModelPickerSheet/index.ts | 1 - .../TargetPickerSheet/TargetPickerSheet.tsx | 117 ---------- .../components/TargetPickerSheet/index.ts | 1 - .../newChatPreferencesStore.ts | 11 +- 22 files changed, 365 insertions(+), 494 deletions(-) create mode 100644 apps/mobile/app/(authenticated)/(home)/new-chat/_layout.tsx create mode 100644 apps/mobile/app/(authenticated)/(home)/new-chat/branch.tsx create mode 100644 apps/mobile/app/(authenticated)/(home)/new-chat/model.tsx create mode 100644 apps/mobile/app/(authenticated)/(home)/new-chat/project.tsx create mode 100644 apps/mobile/screens/(authenticated)/(home)/new-chat/branch/BranchPickerScreen.tsx create mode 100644 apps/mobile/screens/(authenticated)/(home)/new-chat/branch/index.ts create mode 100644 apps/mobile/screens/(authenticated)/(home)/new-chat/model/ModelPickerScreen.tsx create mode 100644 apps/mobile/screens/(authenticated)/(home)/new-chat/model/index.ts create mode 100644 apps/mobile/screens/(authenticated)/(home)/new-chat/project/ProjectPickerScreen.tsx create mode 100644 apps/mobile/screens/(authenticated)/(home)/new-chat/project/index.ts delete mode 100644 apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/BranchPickerSheet/BranchPickerSheet.tsx delete mode 100644 apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/BranchPickerSheet/index.ts delete mode 100644 apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/ModelPickerSheet/ModelPickerSheet.tsx delete mode 100644 apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/ModelPickerSheet/index.ts delete mode 100644 apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/TargetPickerSheet/TargetPickerSheet.tsx delete mode 100644 apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/TargetPickerSheet/index.ts diff --git a/apps/mobile/app/(authenticated)/(home)/_layout.tsx b/apps/mobile/app/(authenticated)/(home)/_layout.tsx index 804ef9bce8b..d18feff733c 100644 --- a/apps/mobile/app/(authenticated)/(home)/_layout.tsx +++ b/apps/mobile/app/(authenticated)/(home)/_layout.tsx @@ -18,6 +18,15 @@ export default function HomeLayout() { sheetGrabberVisible: true, }} /> + ); } diff --git a/apps/mobile/app/(authenticated)/(home)/filter/_layout.tsx b/apps/mobile/app/(authenticated)/(home)/filter/_layout.tsx index d3658240cbb..4a16fb6b87e 100644 --- a/apps/mobile/app/(authenticated)/(home)/filter/_layout.tsx +++ b/apps/mobile/app/(authenticated)/(home)/filter/_layout.tsx @@ -1,4 +1,5 @@ import { Stack } from "expo-router"; +import { SheetCloseButton } from "@/screens/(authenticated)/(home)/components/SheetCloseButton"; export default function FilterLayout() { return ( @@ -8,7 +9,13 @@ export default function FilterLayout() { headerShadowVisible: false, }} > - + , + }} + /> 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..5a8759bbf41 --- /dev/null +++ b/apps/mobile/app/(authenticated)/(home)/new-chat/_layout.tsx @@ -0,0 +1,18 @@ +import { Stack } from "expo-router"; +import { SheetCloseButton } from "@/screens/(authenticated)/(home)/components/SheetCloseButton"; + +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/screens/(authenticated)/(home)/components/SheetCloseButton/SheetCloseButton.tsx b/apps/mobile/screens/(authenticated)/(home)/components/SheetCloseButton/SheetCloseButton.tsx index 18c20227784..40d646a86fc 100644 --- a/apps/mobile/screens/(authenticated)/(home)/components/SheetCloseButton/SheetCloseButton.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/components/SheetCloseButton/SheetCloseButton.tsx @@ -1,4 +1,5 @@ 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"; @@ -7,10 +8,11 @@ export function SheetCloseButton({ onPress, className, }: { - onPress: () => void; + onPress?: () => void; className?: string; }) { const theme = useTheme(); + const router = useRouter(); return ( router.back())} > diff --git a/apps/mobile/screens/(authenticated)/(home)/filter/FilterScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/filter/FilterScreen.tsx index 1a45f2522a1..65de61f262c 100644 --- a/apps/mobile/screens/(authenticated)/(home)/filter/FilterScreen.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/filter/FilterScreen.tsx @@ -2,9 +2,7 @@ import Ionicons from "@expo/vector-icons/Ionicons"; import { useLiveQuery } from "@tanstack/react-db"; import { useRouter } from "expo-router"; import { View } from "react-native"; -import { Text } from "@/components/ui/text"; import { useTheme } from "@/hooks/useTheme"; -import { SheetCloseButton } from "@/screens/(authenticated)/(home)/components/SheetCloseButton"; import { useSelectedHost } from "@/screens/(authenticated)/(home)/hooks/useSelectedHost"; import { SORT_OPTIONS, @@ -42,17 +40,6 @@ export function FilterScreen() { return ( - - - router.back()} /> - - - Filter - - void; +}) { + const theme = useTheme(); + return ( + + + {name} + + {isSelected ? ( + + ) : null} + + ); +} + +export function BranchPickerScreen() { + const router = useRouter(); + const theme = useTheme(); + const [query, setQuery] = useState(""); + const { workspaces } = useHostWorkspaces(); + const { targets, defaultTarget } = useNewChatTargets(workspaces); + 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 ( + + + + + + + + + {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..6712cb04ec9 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/new-chat/model/ModelPickerScreen.tsx @@ -0,0 +1,52 @@ +import Ionicons from "@expo/vector-icons/Ionicons"; +import { SUPERSET_CHAT_MODELS } from "@superset/shared/agent-models"; +import { 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 ( + + {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..0d2bac544ea --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/new-chat/project/ProjectPickerScreen.tsx @@ -0,0 +1,81 @@ +import Ionicons from "@expo/vector-icons/Ionicons"; +import { useRouter } from "expo-router"; +import { Pressable, ScrollView, View } from "react-native"; +import { Text } from "@/components/ui/text"; +import { useHostWorkspaces } from "@/hooks/useHostWorkspaces"; +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 { workspaces } = useHostWorkspaces(); + const { targets, defaultTarget } = useNewChatTargets(workspaces); + 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 ( + + {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/components/NewChatWidget/NewChatWidget.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/NewChatWidget.tsx index a373c62fcb4..0b75a334202 100644 --- a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/NewChatWidget.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/NewChatWidget.tsx @@ -1,5 +1,6 @@ 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 { @@ -29,10 +30,7 @@ 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 { BranchPickerSheet } from "./components/BranchPickerSheet"; import { ContextSheet } from "./components/ContextSheet"; -import { ModelPickerSheet } from "./components/ModelPickerSheet"; -import { TargetPickerSheet } from "./components/TargetPickerSheet"; import { useCreateChatWorkspace } from "./hooks/useCreateChatWorkspace"; import { useNewChatTargets } from "./hooks/useNewChatTargets"; import { useNewChatPreferencesStore } from "./stores/newChatPreferencesStore"; @@ -56,28 +54,23 @@ function NewChatWidgetInner({ }: { workspaces: HostWorkspaceItem[]; }) { + const router = useRouter(); const insets = useSafeAreaInsets(); const controller = usePromptInputController(); - const [targetSheetOpen, setTargetSheetOpen] = useState(false); - const [branchSheetOpen, setBranchSheetOpen] = useState(false); const [contextSheetOpen, setContextSheetOpen] = useState(false); - const [modelSheetOpen, setModelSheetOpen] = useState(false); const [focused, setFocused] = useState(false); - const [explicitTargetKey, setExplicitTargetKey] = useState( - null, - ); - const [baseBranch, setBaseBranch] = useState(null); const modelId = useNewChatPreferencesStore((state) => state.modelId); - const setModelId = useNewChatPreferencesStore((state) => state.setModelId); - const setTargetKey = useNewChatPreferencesStore( - (state) => state.setTargetKey, + 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 === explicitTargetKey) ?? defaultTarget; + targets.find((target) => target.key === targetKey) ?? defaultTarget; const { data: branchData } = useQuery({ queryKey: [ @@ -143,7 +136,9 @@ function NewChatWidgetInner({ setTargetSheetOpen(true)} + onPress={() => + router.push("/(authenticated)/(home)/new-chat/project") + } > setBranchSheetOpen(true)} + onPress={() => + router.push("/(authenticated)/(home)/new-chat/branch") + } > setModelSheetOpen(true)} + onPress={() => + router.push("/(authenticated)/(home)/new-chat/model") + } > {selectedModel?.label ?? "Model"} @@ -211,31 +210,6 @@ function NewChatWidgetInner({ - { - setExplicitTargetKey(target.key); - setTargetKey(target.key); - setBaseBranch(null); - }} - /> - - void; -}) { - const theme = useTheme(); - return ( - - - {name} - - {isSelected ? ( - - ) : null} - - ); -} - -export function BranchPickerSheet({ - isPresented, - onIsPresentedChange, - hostUrl, - projectId, - selectedBranch, - onSelect, -}: { - isPresented: boolean; - onIsPresentedChange: (value: boolean) => void; - hostUrl: string | null; - projectId: string | null; - /** Null = default branch. */ - selectedBranch: string | null; - onSelect: (branch: string | null) => void; -}) { - const theme = useTheme(); - const { width, height } = useWindowDimensions(); - const [query, setQuery] = useState(""); - - const trimmedQuery = query.trim(); - const { data, isLoading } = useQuery({ - queryKey: ["host-service", "branches", hostUrl, projectId, trimmedQuery], - enabled: isPresented && 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 handlePresentedChange = (value: boolean) => { - if (!value) setQuery(""); - onIsPresentedChange(value); - }; - - const selectAndClose = (branch: string | null) => { - onSelect(branch); - handlePresentedChange(false); - }; - - return ( - - - - - - - - handlePresentedChange(false)} - /> - - - Branch - - - - - - - - - - {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)/workspaces/components/NewChatWidget/components/BranchPickerSheet/index.ts b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/BranchPickerSheet/index.ts deleted file mode 100644 index ca77cf357e6..00000000000 --- a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/BranchPickerSheet/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { BranchPickerSheet } from "./BranchPickerSheet"; diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/ModelPickerSheet/ModelPickerSheet.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/ModelPickerSheet/ModelPickerSheet.tsx deleted file mode 100644 index 807dce222b0..00000000000 --- a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/ModelPickerSheet/ModelPickerSheet.tsx +++ /dev/null @@ -1,99 +0,0 @@ -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 { SUPERSET_CHAT_MODELS } from "@superset/shared/agent-models"; -import { Pressable, useWindowDimensions, View } from "react-native"; -import { Text } from "@/components/ui/text"; -import { useTheme } from "@/hooks/useTheme"; -import { SheetCloseButton } from "@/screens/(authenticated)/(home)/components/SheetCloseButton"; -import { hslToHex } from "../../../../utils/hslToHex"; - -export function ModelPickerSheet({ - isPresented, - onIsPresentedChange, - selectedModelId, - onSelect, -}: { - isPresented: boolean; - onIsPresentedChange: (value: boolean) => void; - selectedModelId: string; - onSelect: (modelId: string) => void; -}) { - const theme = useTheme(); - const { width } = useWindowDimensions(); - - return ( - - - - - - - - onIsPresentedChange(false)} - /> - - - Model - - - {SUPERSET_CHAT_MODELS.map((model) => { - const isSelected = model.id === selectedModelId; - return ( - { - onSelect(model.id); - onIsPresentedChange(false); - }} - 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)/workspaces/components/NewChatWidget/components/ModelPickerSheet/index.ts b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/ModelPickerSheet/index.ts deleted file mode 100644 index c4a46af5e93..00000000000 --- a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/ModelPickerSheet/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ModelPickerSheet } from "./ModelPickerSheet"; diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/TargetPickerSheet/TargetPickerSheet.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/TargetPickerSheet/TargetPickerSheet.tsx deleted file mode 100644 index 7d7e24dd87e..00000000000 --- a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/TargetPickerSheet/TargetPickerSheet.tsx +++ /dev/null @@ -1,117 +0,0 @@ -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 { Pressable, useWindowDimensions, View } from "react-native"; -import { Text } from "@/components/ui/text"; -import { useTheme } from "@/hooks/useTheme"; -import { SheetCloseButton } from "@/screens/(authenticated)/(home)/components/SheetCloseButton"; -import { ProjectAvatar } from "@/screens/(authenticated)/(home)/filter/components/ProjectAvatar"; -import { hslToHex } from "../../../../utils/hslToHex"; -import type { NewChatTarget } from "../../hooks/useNewChatTargets"; - -export function TargetPickerSheet({ - isPresented, - onIsPresentedChange, - targets, - selectedKey, - onSelect, -}: { - isPresented: boolean; - onIsPresentedChange: (value: boolean) => void; - targets: NewChatTarget[]; - selectedKey: string | null; - onSelect: (target: NewChatTarget) => void; -}) { - const theme = useTheme(); - const { width } = useWindowDimensions(); - - return ( - - - - - - - - onIsPresentedChange(false)} - /> - - - Project - - - {targets.length === 0 ? ( - - No projects on an online host - - ) : null} - {targets.map((target) => { - const isSelected = target.key === selectedKey; - return ( - { - onSelect(target); - onIsPresentedChange(false); - }} - 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)/workspaces/components/NewChatWidget/components/TargetPickerSheet/index.ts b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/TargetPickerSheet/index.ts deleted file mode 100644 index ab45d964d7a..00000000000 --- a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/components/TargetPickerSheet/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { TargetPickerSheet } from "./TargetPickerSheet"; 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 index 2ee1d8cff0b..31ccc9d5848 100644 --- 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 @@ -10,8 +10,11 @@ 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()( @@ -19,12 +22,18 @@ export const useNewChatPreferencesStore = create()( (set) => ({ modelId: DEFAULT_MODEL_ID, targetKey: null, + baseBranch: null, setModelId: (modelId) => set({ modelId }), - setTargetKey: (targetKey) => set({ targetKey }), + 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, + }), }, ), ); From eba153387a2f31cb7cb32c0dee50484b1d89dc65 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Wed, 8 Jul 2026 18:00:32 -0700 Subject: [PATCH 21/30] fix(mobile): native toolbar close buttons on picker and filter sheets headerLeft renders a custom RN view inside the bar; Stack.Toolbar.Button with the xmark SF Symbol gets the real native bar-button treatment. --- .../mobile/app/(authenticated)/(home)/filter/_layout.tsx | 9 +-------- .../app/(authenticated)/(home)/new-chat/_layout.tsx | 2 -- .../(authenticated)/(home)/filter/FilterScreen.tsx | 5 ++++- .../(home)/new-chat/branch/BranchPickerScreen.tsx | 5 ++++- .../(home)/new-chat/model/ModelPickerScreen.tsx | 5 ++++- .../(home)/new-chat/project/ProjectPickerScreen.tsx | 5 ++++- 6 files changed, 17 insertions(+), 14 deletions(-) diff --git a/apps/mobile/app/(authenticated)/(home)/filter/_layout.tsx b/apps/mobile/app/(authenticated)/(home)/filter/_layout.tsx index 4a16fb6b87e..d3658240cbb 100644 --- a/apps/mobile/app/(authenticated)/(home)/filter/_layout.tsx +++ b/apps/mobile/app/(authenticated)/(home)/filter/_layout.tsx @@ -1,5 +1,4 @@ import { Stack } from "expo-router"; -import { SheetCloseButton } from "@/screens/(authenticated)/(home)/components/SheetCloseButton"; export default function FilterLayout() { return ( @@ -9,13 +8,7 @@ export default function FilterLayout() { headerShadowVisible: false, }} > - , - }} - /> + diff --git a/apps/mobile/app/(authenticated)/(home)/new-chat/_layout.tsx b/apps/mobile/app/(authenticated)/(home)/new-chat/_layout.tsx index 5a8759bbf41..9d35ccc2d27 100644 --- a/apps/mobile/app/(authenticated)/(home)/new-chat/_layout.tsx +++ b/apps/mobile/app/(authenticated)/(home)/new-chat/_layout.tsx @@ -1,5 +1,4 @@ import { Stack } from "expo-router"; -import { SheetCloseButton } from "@/screens/(authenticated)/(home)/components/SheetCloseButton"; export default function NewChatLayout() { return ( @@ -7,7 +6,6 @@ export default function NewChatLayout() { screenOptions={{ headerBackButtonDisplayMode: "minimal", headerShadowVisible: false, - headerLeft: () => , }} > diff --git a/apps/mobile/screens/(authenticated)/(home)/filter/FilterScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/filter/FilterScreen.tsx index 65de61f262c..ffe732810a4 100644 --- a/apps/mobile/screens/(authenticated)/(home)/filter/FilterScreen.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/filter/FilterScreen.tsx @@ -1,6 +1,6 @@ import Ionicons from "@expo/vector-icons/Ionicons"; import { useLiveQuery } from "@tanstack/react-db"; -import { useRouter } from "expo-router"; +import { Stack, useRouter } from "expo-router"; import { View } from "react-native"; import { useTheme } from "@/hooks/useTheme"; import { useSelectedHost } from "@/screens/(authenticated)/(home)/hooks/useSelectedHost"; @@ -40,6 +40,9 @@ export function FilterScreen() { return ( + + router.back()} /> + + + router.back()} /> + diff --git a/apps/mobile/screens/(authenticated)/(home)/new-chat/model/ModelPickerScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/new-chat/model/ModelPickerScreen.tsx index 6712cb04ec9..6aaf2a5cb33 100644 --- a/apps/mobile/screens/(authenticated)/(home)/new-chat/model/ModelPickerScreen.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/new-chat/model/ModelPickerScreen.tsx @@ -1,6 +1,6 @@ import Ionicons from "@expo/vector-icons/Ionicons"; import { SUPERSET_CHAT_MODELS } from "@superset/shared/agent-models"; -import { useRouter } from "expo-router"; +import { Stack, useRouter } from "expo-router"; import { Pressable, ScrollView } from "react-native"; import { Text } from "@/components/ui/text"; import { useTheme } from "@/hooks/useTheme"; @@ -17,6 +17,9 @@ export function ModelPickerScreen() { className="bg-background flex-1 px-6" contentContainerStyle={{ flexGrow: 1, paddingVertical: 8 }} > + + router.back()} /> + {SUPERSET_CHAT_MODELS.map((model) => { const isSelected = model.id === modelId; return ( diff --git a/apps/mobile/screens/(authenticated)/(home)/new-chat/project/ProjectPickerScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/new-chat/project/ProjectPickerScreen.tsx index 0d2bac544ea..64e094f52fa 100644 --- a/apps/mobile/screens/(authenticated)/(home)/new-chat/project/ProjectPickerScreen.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/new-chat/project/ProjectPickerScreen.tsx @@ -1,5 +1,5 @@ import Ionicons from "@expo/vector-icons/Ionicons"; -import { useRouter } from "expo-router"; +import { Stack, useRouter } from "expo-router"; import { Pressable, ScrollView, View } from "react-native"; import { Text } from "@/components/ui/text"; import { useHostWorkspaces } from "@/hooks/useHostWorkspaces"; @@ -28,6 +28,9 @@ export function ProjectPickerScreen() { className="bg-background flex-1 px-6" contentContainerStyle={{ flexGrow: 1, paddingVertical: 8 }} > + + router.back()} /> + {targets.length === 0 ? ( Date: Wed, 8 Jul 2026 18:03:24 -0700 Subject: [PATCH 22/30] fix(mobile): native search bar in the branch picker sheet --- .../new-chat/branch/BranchPickerScreen.tsx | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/apps/mobile/screens/(authenticated)/(home)/new-chat/branch/BranchPickerScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/new-chat/branch/BranchPickerScreen.tsx index 60e8251252c..fad1997c2a5 100644 --- a/apps/mobile/screens/(authenticated)/(home)/new-chat/branch/BranchPickerScreen.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/new-chat/branch/BranchPickerScreen.tsx @@ -3,12 +3,12 @@ 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 { useHostWorkspaces } from "@/hooks/useHostWorkspaces"; import { useTheme } from "@/hooks/useTheme"; import { getHostServiceClientByUrl } from "@/lib/host-service/client"; +import { THEME } from "@/lib/theme"; import { useNewChatTargets } from "@/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/hooks/useNewChatTargets"; import { useNewChatPreferencesStore } from "@/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/stores/newChatPreferencesStore"; @@ -86,25 +86,24 @@ export function BranchPickerScreen() { }; return ( - + router.back()} /> - - - - - - + setQuery(event.nativeEvent.text)} + onCancelButtonPress={() => setQuery("")} + /> From 9b002b9387b0174cc4fa57b6fd1bc7a56bc720c4 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Wed, 8 Jul 2026 18:06:56 -0700 Subject: [PATCH 23/30] fix(mobile): branch search back to an in-form field under the sheet header The native Stack.SearchBar in a formSheet's nested nav bar hid the list; the styled in-flow field under the native header works and matches the reference design. --- .../new-chat/branch/BranchPickerScreen.tsx | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/apps/mobile/screens/(authenticated)/(home)/new-chat/branch/BranchPickerScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/new-chat/branch/BranchPickerScreen.tsx index fad1997c2a5..60e8251252c 100644 --- a/apps/mobile/screens/(authenticated)/(home)/new-chat/branch/BranchPickerScreen.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/new-chat/branch/BranchPickerScreen.tsx @@ -3,12 +3,12 @@ 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 { useHostWorkspaces } from "@/hooks/useHostWorkspaces"; import { useTheme } from "@/hooks/useTheme"; import { getHostServiceClientByUrl } from "@/lib/host-service/client"; -import { THEME } from "@/lib/theme"; import { useNewChatTargets } from "@/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/hooks/useNewChatTargets"; import { useNewChatPreferencesStore } from "@/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/stores/newChatPreferencesStore"; @@ -86,24 +86,25 @@ export function BranchPickerScreen() { }; return ( - + router.back()} /> - setQuery(event.nativeEvent.text)} - onCancelButtonPress={() => setQuery("")} - /> + + + + + + From dc118bdedb0b64aa00a93fd5df4afb91c5d20615 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Wed, 8 Jul 2026 18:34:43 -0700 Subject: [PATCH 24/30] refactor(mobile): collapse useHostWorkspaces to a single-host query - the hook takes the resolved host and runs one workspace.list query (was a fan-out to every online host); background polling drops to one request per interval - fan-out targets/merge utils deleted; cache ops keep their API but are single-target - search is host-scoped like the rest of the list view - new-chat pickers stop fetching workspaces just for the default-target heuristic (useNewChatTargets workspaces param now optional) --- apps/mobile/hooks/useHostWorkspaces/index.ts | 4 +- .../useHostWorkspaces/useHostWorkspaces.ts | 141 ++++++++---------- .../useHostWorkspaces.utils.ts | 69 --------- .../filter/project/ProjectFilterScreen.tsx | 5 +- .../new-chat/branch/BranchPickerScreen.tsx | 40 ++--- .../new-chat/project/ProjectPickerScreen.tsx | 4 +- .../(home)/workspaces/WorkspacesScreen.tsx | 12 +- .../useNewChatTargets/useNewChatTargets.ts | 2 +- 8 files changed, 106 insertions(+), 171 deletions(-) delete mode 100644 apps/mobile/hooks/useHostWorkspaces/useHostWorkspaces.utils.ts diff --git a/apps/mobile/hooks/useHostWorkspaces/index.ts b/apps/mobile/hooks/useHostWorkspaces/index.ts index 3a00e92e341..87e9458fbd4 100644 --- a/apps/mobile/hooks/useHostWorkspaces/index.ts +++ b/apps/mobile/hooks/useHostWorkspaces/index.ts @@ -1,6 +1,8 @@ -export type { HostWorkspaceItem } from "./useHostWorkspaces"; export { + 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 index eb58cfa24fe..78bfdc7937a 100644 --- a/apps/mobile/hooks/useHostWorkspaces/useHostWorkspaces.ts +++ b/apps/mobile/hooks/useHostWorkspaces/useHostWorkspaces.ts @@ -1,105 +1,101 @@ -import { useLiveQuery } from "@tanstack/react-db"; -import { useQueries, useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useMemo } from "react"; -import { getHostServiceClientByUrl } from "@/lib/host-service/client"; -import { useCollections } from "@/screens/(authenticated)/providers/CollectionsProvider"; import { - deriveHostWorkspacesQueryTargets, - getHostWorkspacesQueryKey, - type HostWorkspaceItem, + buildRelayHostUrl, + getHostServiceClientByUrl, type HostWorkspaceRow, - mergeHostWorkspaces, -} from "./useHostWorkspaces.utils"; +} from "@/lib/host-service/client"; -export type { HostWorkspaceItem } from "./useHostWorkspaces.utils"; +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; +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 a host's cached list. */ + /** Optimistically upsert a row into the host's cached list. */ upsertWorkspace: (row: HostWorkspaceRow) => void; - /** Optimistically drop a row from a host's cached list. */ + /** Optimistically drop a row from the host's cached list. */ removeWorkspace: (hostId: string, workspaceId: string) => void; - /** Rollback hammer: refetch a host's list after a failed write. */ + /** Rollback hammer: refetch the host's list after a failed write. */ invalidateHost: (hostId: string) => void; } export interface UseHostWorkspacesResult { workspaces: HostWorkspaceItem[]; /** - * True once every host answered or failed. Gates empty states only — - * existing rows always render (cache-first rule). + * 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; } /** - * The workspace read path (mobile port of desktop's useHostWorkspaces): - * fan out `workspace.list` to every online host via the relay and merge. - * No local host, no event bus — the 30s poll plus focus/pull refetch is - * the healing path. Offline hosts serve nothing; the UI shows them as a - * placeholder. + * 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(): UseHostWorkspacesResult { - const collections = useCollections(); +export function useHostWorkspaces( + host: WorkspacesHost | null, +): UseHostWorkspacesResult { const queryClient = useQueryClient(); - const { data: hosts } = useLiveQuery( - (q) => q.from({ v2Hosts: collections.v2Hosts }), - [collections], - ); - - const targets = useMemo( - () => deriveHostWorkspacesQueryTargets(hosts ?? []), - [hosts], - ); + const hostUrl = host?.isOnline + ? buildRelayHostUrl(host.organizationId, host.machineId) + : null; + const machineId = host?.machineId ?? null; + const queryKey = getHostWorkspacesQueryKey(machineId, hostUrl); - const queries = useQueries({ - queries: targets.map((target) => ({ - queryKey: getHostWorkspacesQueryKey(target), - enabled: target.hostUrl !== null, - refetchInterval: WORKSPACES_REFETCH_INTERVAL_MS, - retry: 1, - networkMode: "always" as const, - queryFn: async (): Promise => { - if (!target.hostUrl) return []; - return getHostServiceClientByUrl(target.hostUrl).workspace.list.query(); - }, - })), + 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( + const workspaces = useMemo( () => - mergeHostWorkspaces( - targets.map((_target, index) => { - const query = queries[index]; - return { - rows: query?.data, - reachable: query?.data !== undefined && !query?.isError, - }; - }), - ), - [targets, queries], + (query.data ?? []).map((row) => ({ + ...row, + hostReachable: !query.isError, + })), + [query.data, query.isError], ); - const isReady = queries.every( - (query, index) => - query.isSuccess || query.isError || targets[index]?.hostUrl === null, - ); + const isReady = hostUrl === null || query.isSuccess || query.isError; const cache = useMemo(() => { - const targetFor = (hostId: string) => - targets.find((target) => target.machineId === hostId); + const key = getHostWorkspacesQueryKey(machineId, hostUrl); return { - resolveHostUrl: (hostId) => targetFor(hostId)?.hostUrl ?? null, + resolveHostUrl: (hostId) => (hostId === machineId ? hostUrl : null), upsertWorkspace: (row) => { - const target = targetFor(row.hostId); - if (!target) return; + if (row.hostId !== machineId) return; queryClient.setQueryData( - getHostWorkspacesQueryKey(target), + key, (rows) => { if (!rows) return [row]; const exists = rows.some((existing) => existing.id === row.id); @@ -112,22 +108,17 @@ export function useHostWorkspaces(): UseHostWorkspacesResult { ); }, removeWorkspace: (hostId, workspaceId) => { - const target = targetFor(hostId); - if (!target) return; - queryClient.setQueryData( - getHostWorkspacesQueryKey(target), - (rows) => rows?.filter((row) => row.id !== workspaceId), + if (hostId !== machineId) return; + queryClient.setQueryData(key, (rows) => + rows?.filter((row) => row.id !== workspaceId), ); }, invalidateHost: (hostId) => { - const target = targetFor(hostId); - if (!target) return; - void queryClient.invalidateQueries({ - queryKey: getHostWorkspacesQueryKey(target), - }); + if (hostId !== machineId) return; + void queryClient.invalidateQueries({ queryKey: key }); }, }; - }, [targets, queryClient]); + }, [machineId, hostUrl, queryClient]); return { workspaces, isReady, cache }; } diff --git a/apps/mobile/hooks/useHostWorkspaces/useHostWorkspaces.utils.ts b/apps/mobile/hooks/useHostWorkspaces/useHostWorkspaces.utils.ts deleted file mode 100644 index 97abe61a66f..00000000000 --- a/apps/mobile/hooks/useHostWorkspaces/useHostWorkspaces.utils.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { - buildRelayHostUrl, - type HostWorkspaceRow, -} from "@/lib/host-service/client"; - -export type { HostWorkspaceRow } from "@/lib/host-service/client"; - -export interface HostWorkspaceItem extends HostWorkspaceRow { - /** False when the row is a cached result and the host stopped answering. */ - hostReachable: boolean; -} - -export interface HostWorkspacesQueryTarget { - machineId: string; - organizationId: string; - /** Null when the host is known but offline. */ - hostUrl: string | null; -} - -export interface HostRowForTargets { - organizationId: string; - machineId: string; - isOnline: boolean; -} - -export function getHostWorkspacesQueryKey( - target: Pick, -) { - return [ - "host-service", - "workspaces", - "list", - target.machineId, - target.hostUrl, - ] as const; -} - -export function deriveHostWorkspacesQueryTargets( - hosts: HostRowForTargets[], -): HostWorkspacesQueryTarget[] { - return hosts.map((host) => ({ - machineId: host.machineId, - organizationId: host.organizationId, - hostUrl: host.isOnline - ? buildRelayHostUrl(host.organizationId, host.machineId) - : null, - })); -} - -export function mergeHostWorkspaces( - hostResults: Array<{ - rows: HostWorkspaceRow[] | undefined; - reachable: boolean; - }>, -): HostWorkspaceItem[] { - const items: HostWorkspaceItem[] = []; - const seenIds = new Set(); - - for (const result of hostResults) { - if (!result.rows) continue; - for (const row of result.rows) { - if (seenIds.has(row.id)) continue; - seenIds.add(row.id); - items.push({ ...row, hostReachable: result.reachable }); - } - } - - return items; -} diff --git a/apps/mobile/screens/(authenticated)/(home)/filter/project/ProjectFilterScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/filter/project/ProjectFilterScreen.tsx index ae838cb9e58..48335a41d5c 100644 --- a/apps/mobile/screens/(authenticated)/(home)/filter/project/ProjectFilterScreen.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/filter/project/ProjectFilterScreen.tsx @@ -15,8 +15,8 @@ export function ProjectFilterScreen() { const router = useRouter(); const theme = useTheme(); const collections = useCollections(); - const { workspaces } = useHostWorkspaces(); const selectedHost = useSelectedHost(); + const { workspaces } = useHostWorkspaces(selectedHost); const projectFilter = useWorkspacesFilterStore( (store) => store.projectFilter, ); @@ -37,14 +37,13 @@ export function ProjectFilterScreen() { const workspaceCounts = useMemo(() => { const counts = new Map(); for (const workspace of workspaces) { - if (workspace.hostId !== selectedHost?.machineId) continue; counts.set( workspace.projectId, (counts.get(workspace.projectId) ?? 0) + 1, ); } return counts; - }, [workspaces, selectedHost]); + }, [workspaces]); const selectedProjectId = projectFilter ?? sortedProjects[0]?.id ?? null; diff --git a/apps/mobile/screens/(authenticated)/(home)/new-chat/branch/BranchPickerScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/new-chat/branch/BranchPickerScreen.tsx index 60e8251252c..9338b1a1629 100644 --- a/apps/mobile/screens/(authenticated)/(home)/new-chat/branch/BranchPickerScreen.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/new-chat/branch/BranchPickerScreen.tsx @@ -6,7 +6,6 @@ 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 { useHostWorkspaces } from "@/hooks/useHostWorkspaces"; import { useTheme } from "@/hooks/useTheme"; import { getHostServiceClientByUrl } from "@/lib/host-service/client"; import { useNewChatTargets } from "@/screens/(authenticated)/(home)/workspaces/components/NewChatWidget/hooks/useNewChatTargets"; @@ -42,8 +41,7 @@ export function BranchPickerScreen() { const router = useRouter(); const theme = useTheme(); const [query, setQuery] = useState(""); - const { workspaces } = useHostWorkspaces(); - const { targets, defaultTarget } = useNewChatTargets(workspaces); + const { targets, defaultTarget } = useNewChatTargets(); const targetKey = useNewChatPreferencesStore((state) => state.targetKey); const baseBranch = useNewChatPreferencesStore((state) => state.baseBranch); const setBaseBranch = useNewChatPreferencesStore( @@ -86,26 +84,34 @@ export function BranchPickerScreen() { }; 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 ? ( diff --git a/apps/mobile/screens/(authenticated)/(home)/new-chat/project/ProjectPickerScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/new-chat/project/ProjectPickerScreen.tsx index 64e094f52fa..2da8ac85102 100644 --- a/apps/mobile/screens/(authenticated)/(home)/new-chat/project/ProjectPickerScreen.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/new-chat/project/ProjectPickerScreen.tsx @@ -2,7 +2,6 @@ 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 { useHostWorkspaces } from "@/hooks/useHostWorkspaces"; 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"; @@ -11,8 +10,7 @@ import { useNewChatPreferencesStore } from "@/screens/(authenticated)/(home)/wor export function ProjectPickerScreen() { const router = useRouter(); const theme = useTheme(); - const { workspaces } = useHostWorkspaces(); - const { targets, defaultTarget } = useNewChatTargets(workspaces); + const { targets, defaultTarget } = useNewChatTargets(); const targetKey = useNewChatPreferencesStore((state) => state.targetKey); const setTargetKey = useNewChatPreferencesStore( (state) => state.setTargetKey, diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/WorkspacesScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/WorkspacesScreen.tsx index f3876eb60a5..70e7c911597 100644 --- a/apps/mobile/screens/(authenticated)/(home)/workspaces/WorkspacesScreen.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/WorkspacesScreen.tsx @@ -4,7 +4,7 @@ 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 { useCallback, useEffect, 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"; @@ -35,6 +35,14 @@ const NAVIGATION_BAR_HEIGHT = 44; export function WorkspacesScreen() { const router = useRouter(); + // TEMP: auto-open branch sheet for layout debugging — remove + useEffect(() => { + const timer = setTimeout( + () => router.push("/(authenticated)/(home)/new-chat/branch"), + 1500, + ); + return () => clearTimeout(timer); + }, [router]); const [sheetOpen, setSheetOpen] = useState(false); const projectFilter = useWorkspacesFilterStore( (store) => store.projectFilter, @@ -54,8 +62,8 @@ export function WorkspacesScreen() { switchOrganization, } = useOrganizations(); - const { workspaces, isReady, cache } = useHostWorkspaces(); const selectedHost = useSelectedHost(); + const { workspaces, isReady, cache } = useHostWorkspaces(selectedHost); const { data: projects } = useLiveQuery( (q) => q.from({ v2Projects: collections.v2Projects }), 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 index f8c535ecac4..be277c1a828 100644 --- 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 @@ -31,7 +31,7 @@ export function targetKeyFor(projectId: string, machineId: string) { * pick: last used target, else the filtered project, else the most recently * updated workspace's target. */ -export function useNewChatTargets(workspaces: HostWorkspaceItem[]): { +export function useNewChatTargets(workspaces: HostWorkspaceItem[] = []): { targets: NewChatTarget[]; defaultTarget: NewChatTarget | null; } { From 41e78d52e515b140e02717fca1bbfb8e30ed1d5e Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Wed, 8 Jul 2026 18:36:50 -0700 Subject: [PATCH 25/30] fix(mobile): satisfy the formSheet header+ScrollView contract in the branch picker react-native-screens' formSheet content wrapper expects exactly [header, ScrollView] as direct subviews and pins the ScrollView over the whole sheet otherwise; RN view flattening was removing the search-field container from the native hierarchy. Render a fragment with a collapsable={false} header and let the wrapper size the list. --- .../(home)/new-chat/branch/BranchPickerScreen.tsx | 2 +- .../(home)/workspaces/WorkspacesScreen.tsx | 10 +--------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/apps/mobile/screens/(authenticated)/(home)/new-chat/branch/BranchPickerScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/new-chat/branch/BranchPickerScreen.tsx index 9338b1a1629..79827d06337 100644 --- a/apps/mobile/screens/(authenticated)/(home)/new-chat/branch/BranchPickerScreen.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/new-chat/branch/BranchPickerScreen.tsx @@ -159,6 +159,6 @@ export function BranchPickerScreen() { ) : null} - + ); } diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/WorkspacesScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/WorkspacesScreen.tsx index 70e7c911597..614d569b858 100644 --- a/apps/mobile/screens/(authenticated)/(home)/workspaces/WorkspacesScreen.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/WorkspacesScreen.tsx @@ -4,7 +4,7 @@ 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, useEffect, useMemo, useState } from "react"; +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"; @@ -35,14 +35,6 @@ const NAVIGATION_BAR_HEIGHT = 44; export function WorkspacesScreen() { const router = useRouter(); - // TEMP: auto-open branch sheet for layout debugging — remove - useEffect(() => { - const timer = setTimeout( - () => router.push("/(authenticated)/(home)/new-chat/branch"), - 1500, - ); - return () => clearTimeout(timer); - }, [router]); const [sheetOpen, setSheetOpen] = useState(false); const projectFilter = useWorkspacesFilterStore( (store) => store.projectFilter, From 372c6b3dcb0d54c5db44110142083df81237db31 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 9 Jul 2026 11:06:19 -0700 Subject: [PATCH 26/30] fix(mobile): resolve chat hooks' workspace host without the deleted v2_workspaces collection useChatThread and useWorkspaceTerminalAgents (from the chat-runtime PR) looked up the workspace row in the Electric collection this branch removed. New useWorkspaceHost hook finds the row by asking each online host, sharing query keys with useHostWorkspaces so navigation from the list resolves from cache. --- apps/mobile/hooks/useHostWorkspaces/index.ts | 1 + .../useHostWorkspaces/useHostWorkspaces.ts | 2 +- apps/mobile/hooks/useWorkspaceHost/index.ts | 4 + .../useWorkspaceHost/useWorkspaceHost.ts | 74 +++++++++++++++++++ .../hooks/useChatThread/useChatThread.ts | 23 +----- .../useWorkspaceTerminalAgents.ts | 19 +---- 6 files changed, 85 insertions(+), 38 deletions(-) create mode 100644 apps/mobile/hooks/useWorkspaceHost/index.ts create mode 100644 apps/mobile/hooks/useWorkspaceHost/useWorkspaceHost.ts diff --git a/apps/mobile/hooks/useHostWorkspaces/index.ts b/apps/mobile/hooks/useHostWorkspaces/index.ts index 87e9458fbd4..47833a495c6 100644 --- a/apps/mobile/hooks/useHostWorkspaces/index.ts +++ b/apps/mobile/hooks/useHostWorkspaces/index.ts @@ -1,4 +1,5 @@ export { + getHostWorkspacesQueryKey, type HostWorkspaceItem, type HostWorkspaceRow, type HostWorkspacesCacheOps, diff --git a/apps/mobile/hooks/useHostWorkspaces/useHostWorkspaces.ts b/apps/mobile/hooks/useHostWorkspaces/useHostWorkspaces.ts index 78bfdc7937a..69e60e62a18 100644 --- a/apps/mobile/hooks/useHostWorkspaces/useHostWorkspaces.ts +++ b/apps/mobile/hooks/useHostWorkspaces/useHostWorkspaces.ts @@ -21,7 +21,7 @@ export interface WorkspacesHost { const WORKSPACES_REFETCH_INTERVAL_MS = 30_000; -function getHostWorkspacesQueryKey( +export function getHostWorkspacesQueryKey( machineId: string | null, hostUrl: string | null, ) { 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..42eea2dd68c --- /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.isPending); + return { workspace, host, isResolving }; + }, [targets, queries, workspaceId]); +} 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(() => { From d06ab9010b23bb3069ade3a7766573830314e8f5 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 9 Jul 2026 11:34:06 -0700 Subject: [PATCH 27/30] fix(mobile): useWorkspaceHost resolving state must ignore disabled queries --- apps/mobile/hooks/useWorkspaceHost/useWorkspaceHost.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/hooks/useWorkspaceHost/useWorkspaceHost.ts b/apps/mobile/hooks/useWorkspaceHost/useWorkspaceHost.ts index 42eea2dd68c..d2470890aa0 100644 --- a/apps/mobile/hooks/useWorkspaceHost/useWorkspaceHost.ts +++ b/apps/mobile/hooks/useWorkspaceHost/useWorkspaceHost.ts @@ -68,7 +68,7 @@ export function useWorkspaceHost( host = target; } }); - const isResolving = !workspace && queries.some((query) => query.isPending); + const isResolving = !workspace && queries.some((query) => query.isLoading); return { workspace, host, isResolving }; }, [targets, queries, workspaceId]); } From 40f0718228bb7dd1c6a4382f6e56d5be0251c70c Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 9 Jul 2026 12:28:41 -0700 Subject: [PATCH 28/30] fix(mobile): format app.config.ts after rebase conflict resolution --- apps/mobile/app.config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 7f3bfa549c3..c722d89d0de 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -48,13 +48,13 @@ export default ({ config }: ConfigContext) => ({ [ "expo-image-picker", { - photosPermission: + photosPermission: "Superset needs access to your photo library so you can attach images to chat messages.", cameraPermission: false, microphonePermission: false, }, ], - "expo-document-picker", + "expo-document-picker", [ "expo-media-library", { From 0059f1694e7ef8261d6aa4c3049191a76fa6abc3 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 9 Jul 2026 12:30:28 -0700 Subject: [PATCH 29/30] docs(host-service): document the interim dist-types machinery and its revert path --- apps/mobile/lib/host-service/client.ts | 2 + .../host-service/docs/interim-router-types.md | 65 +++++++++++++++++++ packages/host-service/src/trpc/index.ts | 1 + 3 files changed, 68 insertions(+) create mode 100644 packages/host-service/docs/interim-router-types.md diff --git a/apps/mobile/lib/host-service/client.ts b/apps/mobile/lib/host-service/client.ts index 832629dd13d..a5276e16cab 100644 --- a/apps/mobile/lib/host-service/client.ts +++ b/apps/mobile/lib/host-service/client.ts @@ -1,3 +1,5 @@ +// 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"; 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/src/trpc/index.ts b/packages/host-service/src/trpc/index.ts index b3890a3c28f..dd899df6f29 100644 --- a/packages/host-service/src/trpc/index.ts +++ b/packages/host-service/src/trpc/index.ts @@ -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"; From 61d1b839bfd6e855bedfdd2fc34ac72ee834d479 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 9 Jul 2026 12:45:04 -0700 Subject: [PATCH 30/30] fix(mobile): sort devDependencies alphabetically (sherif) --- apps/mobile/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index bd740294cd3..717e1128f7c 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -116,11 +116,11 @@ "devDependencies": { "@babel/preset-typescript": "7.29.7", "@gorhom/bottom-sheet": "5.2.14", - "@superset/host-service": "workspace:*", - "@trpc/server": "11.16.0", "@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",