Switch to double quotes and reformat codebase with Biome

This commit is contained in:
Mohamed Boudra
2026-03-21 20:22:15 +07:00
parent ab40786882
commit f6579dbeb4
805 changed files with 38132 additions and 40175 deletions
+19 -19
View File
@@ -1,36 +1,36 @@
export const WELCOME_ROUTE = '/welcome'
export const WELCOME_ROUTE = "/welcome";
export function shouldWaitOnStartupRace(input: {
onlineServerId: string | null
hasTimedOut: boolean
isDesktopStartupRace: boolean
daemonCount: number
pathname: string
onlineServerId: string | null;
hasTimedOut: boolean;
isDesktopStartupRace: boolean;
daemonCount: number;
pathname: string;
}): boolean {
if (input.onlineServerId) {
return false
return false;
}
if (input.pathname === WELCOME_ROUTE) {
return false
return false;
}
if (input.hasTimedOut) {
return false
return false;
}
return input.isDesktopStartupRace || input.daemonCount > 0
return input.isDesktopStartupRace || input.daemonCount > 0;
}
export function shouldRedirectToWelcome(input: {
onlineServerId: string | null
hasTimedOut: boolean
pathname: string
isDesktopStartupRace: boolean
daemonCount: number
onlineServerId: string | null;
hasTimedOut: boolean;
pathname: string;
isDesktopStartupRace: boolean;
daemonCount: number;
}): boolean {
if (input.onlineServerId || !input.hasTimedOut) {
return false
return false;
}
if (input.pathname !== '/' && input.pathname !== '') {
return false
if (input.pathname !== "/" && input.pathname !== "") {
return false;
}
return input.isDesktopStartupRace || input.daemonCount > 0
return input.isDesktopStartupRace || input.daemonCount > 0;
}
@@ -2,9 +2,7 @@ import { useEffect, useRef } from "react";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useSessionStore } from "@/stores/session-store";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import {
buildHostRootRoute,
} from "@/utils/host-routes";
import { buildHostRootRoute } from "@/utils/host-routes";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
export default function HostAgentReadyRoute() {
@@ -43,7 +41,7 @@ export default function HostAgentReadyRoute() {
serverId,
workspaceId: normalizedCwd,
target: { kind: "agent", agentId },
}) as any
}) as any,
);
}
}, [agentCwd, agentId, router, serverId]);
@@ -87,7 +85,7 @@ export default function HostAgentReadyRoute() {
serverId,
workspaceId: cwd,
target: { kind: "agent", agentId },
}) as any
}) as any,
);
return;
}
+8 -17
View File
@@ -17,11 +17,11 @@ export default function HostIndexRoute() {
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
const { isLoading: preferencesLoading } = useFormPreferences();
const sessionAgents = useSessionStore(
(state) => (serverId ? state.sessions[serverId]?.agents : undefined)
const sessionAgents = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.agents : undefined,
);
const sessionWorkspaces = useSessionStore(
(state) => (serverId ? state.sessions[serverId]?.workspaces : undefined)
const sessionWorkspaces = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.workspaces : undefined,
);
useEffect(() => {
@@ -44,12 +44,10 @@ export default function HostIndexRoute() {
? Array.from(sessionAgents.values()).filter((agent) => !agent.archivedAt)
: [];
visibleAgents.sort(
(left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime()
(left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime(),
);
const visibleWorkspaces = sessionWorkspaces
? Array.from(sessionWorkspaces.values())
: [];
const visibleWorkspaces = sessionWorkspaces ? Array.from(sessionWorkspaces.values()) : [];
visibleWorkspaces.sort((left, right) => {
const leftTime = left.activityAt?.getTime() ?? Number.NEGATIVE_INFINITY;
const rightTime = right.activityAt?.getTime() ?? Number.NEGATIVE_INFINITY;
@@ -63,7 +61,7 @@ export default function HostIndexRoute() {
serverId,
workspaceId: primaryAgent.cwd.trim(),
target: { kind: "agent", agentId: primaryAgent.id },
}) as any
}) as any,
);
return;
}
@@ -78,14 +76,7 @@ export default function HostIndexRoute() {
}, HOST_ROOT_REDIRECT_DELAY_MS);
return () => clearTimeout(timer);
}, [
pathname,
preferencesLoading,
router,
serverId,
sessionAgents,
sessionWorkspaces,
]);
}, [pathname, preferencesLoading, router, serverId, sessionAgents, sessionWorkspaces]);
return null;
}
@@ -1,80 +1,82 @@
import { useEffect, useRef } from 'react'
import { useGlobalSearchParams, useLocalSearchParams, useRouter } from 'expo-router'
import type { WorkspaceTabTarget } from '@/stores/workspace-tabs-store'
import { WorkspaceScreen } from '@/screens/workspace/workspace-screen'
import { useEffect, useRef } from "react";
import { useGlobalSearchParams, useLocalSearchParams, useRouter } from "expo-router";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
import {
buildHostWorkspaceRoute,
decodeWorkspaceIdFromPathSegment,
parseWorkspaceOpenIntent,
type WorkspaceOpenIntent,
} from '@/utils/host-routes'
import { prepareWorkspaceTab } from '@/utils/workspace-navigation'
} from "@/utils/host-routes";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
function getParamValue(value: string | string[] | undefined): string {
if (typeof value === 'string') {
return value.trim()
if (typeof value === "string") {
return value.trim();
}
if (Array.isArray(value)) {
const firstValue = value[0]
return typeof firstValue === 'string' ? firstValue.trim() : ''
const firstValue = value[0];
return typeof firstValue === "string" ? firstValue.trim() : "";
}
return ''
return "";
}
function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarget {
if (openIntent.kind === 'agent') {
return { kind: 'agent', agentId: openIntent.agentId }
if (openIntent.kind === "agent") {
return { kind: "agent", agentId: openIntent.agentId };
}
if (openIntent.kind === 'terminal') {
return { kind: 'terminal', terminalId: openIntent.terminalId }
if (openIntent.kind === "terminal") {
return { kind: "terminal", terminalId: openIntent.terminalId };
}
if (openIntent.kind === 'file') {
return { kind: 'file', path: openIntent.path }
if (openIntent.kind === "file") {
return { kind: "file", path: openIntent.path };
}
return { kind: 'draft', draftId: openIntent.draftId }
return { kind: "draft", draftId: openIntent.draftId };
}
export default function HostWorkspaceLayout() {
const router = useRouter()
const consumedIntentRef = useRef<string | null>(null)
const router = useRouter();
const consumedIntentRef = useRef<string | null>(null);
const params = useLocalSearchParams<{
serverId?: string | string[]
workspaceId?: string | string[]
}>()
serverId?: string | string[];
workspaceId?: string | string[];
}>();
const globalParams = useGlobalSearchParams<{
open?: string | string[]
}>()
const serverId = getParamValue(params.serverId)
const workspaceValue = getParamValue(params.workspaceId)
const workspaceId = workspaceValue ? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? '') : ''
const openValue = getParamValue(globalParams.open)
open?: string | string[];
}>();
const serverId = getParamValue(params.serverId);
const workspaceValue = getParamValue(params.workspaceId);
const workspaceId = workspaceValue
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
: "";
const openValue = getParamValue(globalParams.open);
useEffect(() => {
if (!openValue) {
return
return;
}
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
if (consumedIntentRef.current === consumptionKey) {
return
return;
}
consumedIntentRef.current = consumptionKey
consumedIntentRef.current = consumptionKey;
const openIntent = parseWorkspaceOpenIntent(openValue)
const openIntent = parseWorkspaceOpenIntent(openValue);
const route = openIntent
? prepareWorkspaceTab({
serverId,
workspaceId,
target: getOpenIntentTarget(openIntent),
pin: openIntent.kind === 'agent',
pin: openIntent.kind === "agent",
})
: buildHostWorkspaceRoute(serverId, workspaceId)
: buildHostWorkspaceRoute(serverId, workspaceId);
router.replace(route as any)
}, [openValue, router, serverId, workspaceId])
router.replace(route as any);
}, [openValue, router, serverId, workspaceId]);
if (openValue) {
return null
return null;
}
return (
@@ -83,5 +85,5 @@ export default function HostWorkspaceLayout() {
serverId={serverId}
workspaceId={workspaceId}
/>
)
);
}
+51 -51
View File
@@ -1,82 +1,82 @@
import { useEffect, useSyncExternalStore, useState } from 'react'
import { usePathname, useRouter } from 'expo-router'
import { useHosts } from '@/runtime/host-runtime'
import { shouldUseDesktopDaemon } from '@/desktop/daemon/desktop-daemon'
import { buildHostRootRoute } from '@/utils/host-routes'
import { StartupSplashScreen } from '@/screens/startup-splash-screen'
import { WelcomeScreen } from '@/components/welcome-screen'
import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime'
import { useEffect, useSyncExternalStore, useState } from "react";
import { usePathname, useRouter } from "expo-router";
import { useHosts } from "@/runtime/host-runtime";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { buildHostRootRoute } from "@/utils/host-routes";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import { WelcomeScreen } from "@/components/welcome-screen";
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
import {
shouldRedirectToWelcome,
shouldWaitOnStartupRace,
WELCOME_ROUTE,
} from '@/app-support/index-startup'
} from "@/app-support/index-startup";
const STARTUP_TIMEOUT_MS = 30_000
const STARTUP_TIMEOUT_MS = 30_000;
function useAnyHostOnline(serverIds: string[]): string | null {
const runtime = getHostRuntimeStore()
const runtime = getHostRuntimeStore();
return useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => {
let firstOnlineServerId: string | null = null
let firstOnlineAt: string | null = null
let firstOnlineServerId: string | null = null;
let firstOnlineAt: string | null = null;
for (const serverId of serverIds) {
const snapshot = runtime.getSnapshot(serverId)
const lastOnlineAt = snapshot?.lastOnlineAt ?? null
const snapshot = runtime.getSnapshot(serverId);
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
continue
continue;
}
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
firstOnlineAt = lastOnlineAt
firstOnlineServerId = serverId
firstOnlineAt = lastOnlineAt;
firstOnlineServerId = serverId;
}
}
return firstOnlineServerId
return firstOnlineServerId;
},
() => {
let firstOnlineServerId: string | null = null
let firstOnlineAt: string | null = null
let firstOnlineServerId: string | null = null;
let firstOnlineAt: string | null = null;
for (const serverId of serverIds) {
const snapshot = runtime.getSnapshot(serverId)
const lastOnlineAt = snapshot?.lastOnlineAt ?? null
const snapshot = runtime.getSnapshot(serverId);
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
continue
continue;
}
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
firstOnlineAt = lastOnlineAt
firstOnlineServerId = serverId
firstOnlineAt = lastOnlineAt;
firstOnlineServerId = serverId;
}
}
return firstOnlineServerId
}
)
return firstOnlineServerId;
},
);
}
export default function Index() {
const router = useRouter()
const pathname = usePathname()
const daemons = useHosts()
const [hasTimedOut, setHasTimedOut] = useState(false)
const isDesktopStartupRace = shouldUseDesktopDaemon()
const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId))
const router = useRouter();
const pathname = usePathname();
const daemons = useHosts();
const [hasTimedOut, setHasTimedOut] = useState(false);
const isDesktopStartupRace = shouldUseDesktopDaemon();
const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId));
useEffect(() => {
const timer = setTimeout(() => {
setHasTimedOut(true)
}, STARTUP_TIMEOUT_MS)
setHasTimedOut(true);
}, STARTUP_TIMEOUT_MS);
return () => {
clearTimeout(timer)
}
}, [])
clearTimeout(timer);
};
}, []);
useEffect(() => {
if (!onlineServerId) {
return
return;
}
if (pathname !== '/' && pathname !== '') {
return
if (pathname !== "/" && pathname !== "") {
return;
}
router.replace(buildHostRootRoute(onlineServerId) as any)
}, [onlineServerId, pathname, router])
router.replace(buildHostRootRoute(onlineServerId) as any);
}, [onlineServerId, pathname, router]);
useEffect(() => {
if (
@@ -88,10 +88,10 @@ export default function Index() {
daemonCount: daemons.length,
})
) {
return
return;
}
router.replace(WELCOME_ROUTE as any)
}, [daemons.length, hasTimedOut, isDesktopStartupRace, onlineServerId, pathname, router])
router.replace(WELCOME_ROUTE as any);
}, [daemons.length, hasTimedOut, isDesktopStartupRace, onlineServerId, pathname, router]);
if (
shouldWaitOnStartupRace({
@@ -102,12 +102,12 @@ export default function Index() {
pathname,
})
) {
return <StartupSplashScreen />
return <StartupSplashScreen />;
}
if (!onlineServerId) {
return <WelcomeScreen />
return <WelcomeScreen />;
}
return null
return null;
}
+21 -20
View File
@@ -11,10 +11,7 @@ import { NameHostModal } from "@/components/name-host-modal";
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
import { connectToDaemon } from "@/utils/test-daemon-connection";
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
import {
buildHostRootRoute,
buildHostSettingsRoute,
} from "@/utils/host-routes";
import { buildHostRootRoute, buildHostSettingsRoute } from "@/utils/host-routes";
const styles = StyleSheet.create((theme) => ({
container: {
@@ -148,8 +145,7 @@ export default function PairScanScreen() {
targetServerId?: string;
}>();
const source = typeof params.source === "string" ? params.source : "settings";
const sourceServerId =
typeof params.sourceServerId === "string" ? params.sourceServerId : null;
const sourceServerId = typeof params.sourceServerId === "string" ? params.sourceServerId : null;
const targetServerId = typeof params.targetServerId === "string" ? params.targetServerId : null;
const daemons = useHosts();
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl, renameHost } = useHostMutations();
@@ -157,15 +153,22 @@ export default function PairScanScreen() {
const [permission, requestPermission] = useCameraPermissions();
const [isPairing, setIsPairing] = useState(false);
const lastScannedRef = useRef<string | null>(null);
const [pendingNameHost, setPendingNameHost] = useState<{ serverId: string; hostname: string | null } | null>(null);
const [pendingNameHost, setPendingNameHost] = useState<{
serverId: string;
hostname: string | null;
} | null>(null);
const pendingNameHostname = useSessionStore(
useCallback(
(state) => {
if (!pendingNameHost) return null;
return state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ?? pendingNameHost.hostname ?? null;
return (
state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ??
pendingNameHost.hostname ??
null
);
},
[pendingNameHost]
)
[pendingNameHost],
),
);
const returnToSource = useCallback(
@@ -190,7 +193,7 @@ export default function PairScanScreen() {
router.replace(buildHostSettingsRoute(settingsServerId) as any);
}
},
[router, source, sourceServerId, targetServerId]
[router, source, sourceServerId, targetServerId],
);
const closeToSource = useCallback(() => {
@@ -238,7 +241,10 @@ export default function PairScanScreen() {
if (targetServerId && offer.serverId !== targetServerId) {
lastScannedRef.current = null;
Alert.alert("Wrong daemon", `That QR code belongs to ${offer.serverId}, not ${targetServerId}.`);
Alert.alert(
"Wrong daemon",
`That QR code belongs to ${offer.serverId}, not ${targetServerId}.`,
);
return;
}
@@ -270,7 +276,7 @@ export default function PairScanScreen() {
setIsPairing(false);
}
},
[daemons, isPairing, pendingNameHost, returnToSource, targetServerId, upsertDaemonFromOfferUrl]
[daemons, isPairing, pendingNameHost, returnToSource, targetServerId, upsertDaemonFromOfferUrl],
);
if (Platform.OS === "web") {
@@ -334,10 +340,7 @@ export default function PairScanScreen() {
<Text style={styles.permissionBody}>
Allow camera access to scan the pairing QR code from your daemon.
</Text>
<Pressable
style={styles.permissionButton}
onPress={() => void requestPermission()}
>
<Pressable style={styles.permissionButton} onPress={() => void requestPermission()}>
<Text style={styles.permissionButtonText}>Grant permission</Text>
</Pressable>
</View>
@@ -356,9 +359,7 @@ export default function PairScanScreen() {
<View style={[styles.corner, styles.cornerBL]} />
<View style={[styles.corner, styles.cornerBR]} />
</View>
<Text style={styles.helperText}>
Point your camera at the pairing QR code.
</Text>
<Text style={styles.helperText}>Point your camera at the pairing QR code.</Text>
{isPairing ? (
<Text style={[styles.helperText, { color: theme.colors.foreground }]}>
Pairing…
+1 -3
View File
@@ -18,9 +18,7 @@ export default function LegacySettingsRoute() {
return null;
}
if (preferences.serverId) {
const match = daemons.find(
(daemon) => daemon.serverId === preferences.serverId
);
const match = daemons.find((daemon) => daemon.serverId === preferences.serverId);
if (match) {
return match.serverId;
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { WelcomeScreen } from '@/components/welcome-screen'
import { WelcomeScreen } from "@/components/welcome-screen";
export default function WelcomeRoute() {
return <WelcomeScreen />
return <WelcomeScreen />;
}
@@ -86,10 +86,7 @@ export function createLocalFileAttachmentStore(params: {
storageType: Extract<AttachmentStorageType, "desktop-file" | "native-file">;
baseDirectoryName: string;
resolvePreviewUrl: (attachment: AttachmentMetadata) => Promise<string>;
releasePreviewUrl?: (input: {
attachment: AttachmentMetadata;
url: string;
}) => Promise<void>;
releasePreviewUrl?: (input: { attachment: AttachmentMetadata; url: string }) => Promise<void>;
}): AttachmentStore {
const baseDirectory = FileSystem.cacheDirectory
? `${FileSystem.cacheDirectory}${params.baseDirectoryName}/`
@@ -201,7 +198,7 @@ export function createLocalFileAttachmentStore(params: {
await FileSystem.deleteAsync(`${baseDirectory}${entryName}`, {
idempotent: true,
});
})
}),
);
},
};
+6 -8
View File
@@ -47,7 +47,7 @@ export async function persistAttachmentFromFileUri(input: {
}
export async function encodeAttachmentsForSend(
attachments: readonly AttachmentMetadata[] | undefined
attachments: readonly AttachmentMetadata[] | undefined,
): Promise<Array<{ data: string; mimeType: string }> | undefined> {
if (!attachments || attachments.length === 0) {
return undefined;
@@ -69,18 +69,16 @@ export async function encodeAttachmentsForSend(
});
return null;
}
})
}),
);
const valid = encoded.filter(
(entry): entry is { data: string; mimeType: string } => entry !== null
(entry): entry is { data: string; mimeType: string } => entry !== null,
);
return valid.length > 0 ? valid : undefined;
}
export async function resolveAttachmentPreviewUrl(
attachment: AttachmentMetadata
): Promise<string> {
export async function resolveAttachmentPreviewUrl(attachment: AttachmentMetadata): Promise<string> {
const store = await getAttachmentStore();
return await store.resolvePreviewUrl({ attachment });
}
@@ -97,7 +95,7 @@ export async function releaseAttachmentPreviewUrl(input: {
}
export async function deleteAttachments(
attachments: readonly AttachmentMetadata[] | undefined
attachments: readonly AttachmentMetadata[] | undefined,
): Promise<void> {
if (!attachments || attachments.length === 0) {
return;
@@ -113,7 +111,7 @@ export async function deleteAttachments(
error,
});
}
})
}),
);
}
+2 -6
View File
@@ -13,15 +13,11 @@ async function createAttachmentStore(): Promise<AttachmentStore> {
return createDesktopAttachmentStore();
}
const { createIndexedDbAttachmentStore } = await import(
"./web/indexeddb-attachment-store"
);
const { createIndexedDbAttachmentStore } = await import("./web/indexeddb-attachment-store");
return createIndexedDbAttachmentStore();
}
const { createNativeFileAttachmentStore } = await import(
"./native/native-file-attachment-store"
);
const { createNativeFileAttachmentStore } = await import("./native/native-file-attachment-store");
return createNativeFileAttachmentStore();
}
@@ -1,12 +1,9 @@
import { useEffect, useRef, useState } from "react";
import type { AttachmentMetadata } from "@/attachments/types";
import {
releaseAttachmentPreviewUrl,
resolveAttachmentPreviewUrl,
} from "@/attachments/service";
import { releaseAttachmentPreviewUrl, resolveAttachmentPreviewUrl } from "@/attachments/service";
export function useAttachmentPreviewUrl(
attachment: AttachmentMetadata | null | undefined
attachment: AttachmentMetadata | null | undefined,
): string | null {
const [url, setUrl] = useState<string | null>(null);
const activeAttachmentRef = useRef<AttachmentMetadata | null>(null);
@@ -52,12 +49,7 @@ export function useAttachmentPreviewUrl(
url: currentUrl,
});
};
}, [
attachment?.id,
attachment?.storageType,
attachment?.storageKey,
attachment?.mimeType,
]);
}, [attachment?.id, attachment?.storageType, attachment?.storageKey, attachment?.mimeType]);
return url;
}
@@ -53,7 +53,7 @@ function openAttachmentDb(): Promise<IDBDatabase> {
function runTx<T>(
db: IDBDatabase,
mode: IDBTransactionMode,
run: (store: IDBObjectStore) => IDBRequest<T>
run: (store: IDBObjectStore) => IDBRequest<T>,
): Promise<T> {
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, mode);
@@ -78,7 +78,10 @@ async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; m
const source = input.source;
if (source.kind === "blob") {
const mimeType = normalizeMimeType(input.mimeType ?? source.blob.type);
const blob = source.blob.type === mimeType ? source.blob : source.blob.slice(0, source.blob.size, mimeType);
const blob =
source.blob.type === mimeType
? source.blob
: source.blob.slice(0, source.blob.size, mimeType);
return { blob, mimeType };
}
@@ -104,7 +107,7 @@ async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; m
async function loadBlob(db: IDBDatabase, id: string): Promise<Blob> {
const record = await runTx<StoredBlobRecord | undefined>(db, "readonly", (store) =>
store.get(id)
store.get(id),
);
if (!record?.blob) {
throw new Error(`Attachment ${id} was not found in IndexedDB.`);
@@ -125,7 +128,7 @@ export function createIndexedDbAttachmentStore(): AttachmentStore {
try {
await runTx(db, "readwrite", (store) =>
store.put({ id, blob, createdAt, fileName } satisfies StoredBlobRecord)
store.put({ id, blob, createdAt, fileName } satisfies StoredBlobRecord),
);
} finally {
db.close();
@@ -184,7 +187,9 @@ export function createIndexedDbAttachmentStore(): AttachmentStore {
const cursorRequest = store.openCursor();
cursorRequest.onerror = () => {
reject(cursorRequest.error ?? new Error("Failed to iterate IndexedDB attachment store."));
reject(
cursorRequest.error ?? new Error("Failed to iterate IndexedDB attachment store."),
);
};
cursorRequest.onsuccess = () => {
@@ -68,16 +68,16 @@ const styles = StyleSheet.create((theme) => ({
backButtonText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: '600',
textAlign: 'center',
fontWeight: "600",
textAlign: "center",
},
scrollView: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
},
processItem: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
@@ -104,7 +104,7 @@ const styles = StyleSheet.create((theme) => ({
processText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
fontWeight: '500',
fontWeight: "500",
},
processTextActive: {
color: theme.colors.primaryForeground,
@@ -147,20 +147,21 @@ export function ActiveProcesses({
onPress={onSelectOrchestrator}
style={({ pressed }) => [
styles.processItem,
viewMode === 'orchestrator' ? styles.processItemActive : styles.processItemInactive,
viewMode === "orchestrator" ? styles.processItemActive : styles.processItemInactive,
pressed && { opacity: 0.7 },
]}
>
<View style={styles.agentIcon} />
<Text style={[
styles.processText,
viewMode === 'orchestrator' && styles.processTextActive,
]}>Orchestrator</Text>
<Text
style={[styles.processText, viewMode === "orchestrator" && styles.processTextActive]}
>
Orchestrator
</Text>
</Pressable>
{/* Agent pills */}
{agents.map((agent) => {
const isActive = viewMode === 'agent' && activeAgentId === agent.id;
const isActive = viewMode === "agent" && activeAgentId === agent.id;
return (
<Pressable
@@ -174,15 +175,21 @@ export function ActiveProcesses({
>
<View style={styles.agentIcon} />
<Text style={[
styles.processText,
isActive && styles.processTextActive,
]}>{agent.id.substring(0, 8)}</Text>
<Text style={[styles.processText, isActive && styles.processTextActive]}>
{agent.id.substring(0, 8)}
</Text>
<View style={[styles.statusDot, { backgroundColor: getAgentStatusColor(agent.status) }]} />
<View
style={[styles.statusDot, { backgroundColor: getAgentStatusColor(agent.status) }]}
/>
{agent.currentModeId && (
<View style={[styles.modeIndicator, { backgroundColor: getModeColor(agent.currentModeId) }]} />
<View
style={[
styles.modeIndicator,
{ backgroundColor: getModeColor(agent.currentModeId) },
]}
/>
)}
</Pressable>
);
@@ -1,15 +1,7 @@
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
import type { ReactNode } from "react";
import { createPortal } from "react-dom";
import {
Modal,
Platform,
Pressable,
ScrollView,
Text,
TextInput,
View,
} from "react-native";
import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import type { TextInputProps } from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
@@ -121,8 +113,7 @@ export function AdaptiveModalSheet({
testID,
}: AdaptiveModalSheetProps) {
const { theme } = useUnistyles();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const sheetRef = useRef<BottomSheetModal>(null);
const dismissingForVisibilityRef = useRef(false);
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
@@ -148,19 +139,14 @@ export function AdaptiveModalSheet({
onClose();
}
},
[onClose]
[onClose],
);
const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
opacity={0.45}
/>
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
),
[]
[],
);
if (isMobile) {
@@ -180,11 +166,7 @@ export function AdaptiveModalSheet({
>
<View style={styles.bottomSheetHeader}>
<Text style={styles.title}>{title}</Text>
<Pressable
accessibilityLabel="Close"
style={styles.closeButton}
onPress={onClose}
>
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
<X size={16} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
@@ -209,11 +191,7 @@ export function AdaptiveModalSheet({
<View style={styles.desktopCard}>
<View style={styles.header}>
<Text style={styles.title}>{title}</Text>
<Pressable
accessibilityLabel="Close"
style={styles.closeButton}
onPress={onClose}
>
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
<X size={16} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
@@ -254,13 +232,12 @@ export function AdaptiveModalSheet({
*/
export const AdaptiveTextInput = forwardRef<TextInput, TextInputProps>(
function AdaptiveTextInput(props, ref) {
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
if (isMobile) {
return <BottomSheetTextInput ref={ref as any} {...props} />;
}
return <TextInput ref={ref} {...props} />;
}
},
);
@@ -60,8 +60,17 @@ export function AddHostMethodModal({
}, [onPasteLink]);
return (
<AdaptiveModalSheet title="Add connection" visible={visible} onClose={onClose} testID="add-host-method-modal">
<Pressable style={styles.option} onPress={handleDirect} accessibilityLabel="Direct connection">
<AdaptiveModalSheet
title="Add connection"
visible={visible}
onClose={onClose}
testID="add-host-method-modal"
>
<Pressable
style={styles.option}
onPress={handleDirect}
accessibilityLabel="Direct connection"
>
<Link2 size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}>
<Text style={styles.optionText}>Direct connection</Text>
@@ -79,7 +88,11 @@ export function AddHostMethodModal({
</Pressable>
) : null}
<Pressable style={styles.option} onPress={handlePaste} accessibilityLabel="Paste pairing link">
<Pressable
style={styles.option}
onPress={handlePaste}
accessibilityLabel="Paste pairing link"
>
<ClipboardPaste size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}>
<Text style={styles.optionText}>Paste pairing link</Text>
+43 -11
View File
@@ -60,8 +60,8 @@ function formatTechnicalTransportDetails(details: Array<string | null>): string
.map((value) => normalizeTransportMessage(value))
.filter((value): value is string => Boolean(value))
.map((value) => value.trim())
.filter((value) => value.length > 0)
)
.filter((value) => value.length > 0),
),
);
if (unique.length === 0) return null;
@@ -78,7 +78,10 @@ function formatTechnicalTransportDetails(details: Array<string | null>): string
return unique.join(" — ");
}
function buildConnectionFailureCopy(endpoint: string, error: unknown): { title: string; detail: string | null; raw: string | null } {
function buildConnectionFailureCopy(
endpoint: string,
error: unknown,
): { title: string; detail: string | null; raw: string | null } {
const title = `We failed to connect to ${endpoint}.`;
const raw = (() => {
@@ -109,8 +112,13 @@ function buildConnectionFailureCopy(endpoint: string, error: unknown): { title:
detail = "Host not found. Check the hostname and try again.";
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
detail = "Host is unreachable. Check your network and firewall.";
} else if (rawLower.includes("certificate") || rawLower.includes("tls") || rawLower.includes("ssl")) {
detail = "TLS error. Direct connections use an unencrypted local connection. Use relay for remote access.";
} else if (
rawLower.includes("certificate") ||
rawLower.includes("tls") ||
rawLower.includes("ssl")
) {
detail =
"TLS error. Direct connections use an unencrypted local connection. Use relay for remote access.";
} else if (raw) {
detail = "Unable to connect. Check the host/port and that the daemon is reachable.";
} else {
@@ -125,15 +133,25 @@ export interface AddHostModalProps {
onClose: () => void;
targetServerId?: string;
onCancel?: () => void;
onSaved?: (result: { profile: HostProfile; serverId: string; hostname: string | null; isNewHost: boolean }) => void;
onSaved?: (result: {
profile: HostProfile;
serverId: string;
hostname: string | null;
isNewHost: boolean;
}) => void;
}
export function AddHostModal({ visible, onClose, onCancel, onSaved, targetServerId }: AddHostModalProps) {
export function AddHostModal({
visible,
onClose,
onCancel,
onSaved,
targetServerId,
}: AddHostModalProps) {
const { theme } = useUnistyles();
const daemons = useHosts();
const { upsertDirectConnection } = useHostMutations();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const hostInputRef = useRef<TextInput>(null);
@@ -220,10 +238,24 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved, targetServer
} finally {
setIsSaving(false);
}
}, [daemons, endpointRaw, handleClose, isMobile, isSaving, onSaved, targetServerId, upsertDirectConnection]);
}, [
daemons,
endpointRaw,
handleClose,
isMobile,
isSaving,
onSaved,
targetServerId,
upsertDirectConnection,
]);
return (
<AdaptiveModalSheet title="Direct connection" visible={visible} onClose={handleClose} testID="add-host-modal">
<AdaptiveModalSheet
title="Direct connection"
visible={visible}
onClose={handleClose}
testID="add-host-modal"
>
<Text style={styles.helper}>Enter the address of a Paseo server.</Text>
<View style={styles.field}>
+81 -111
View File
@@ -1,76 +1,77 @@
import { useState } from 'react';
import { View, Text, Pressable } from 'react-native';
import { StyleSheet } from 'react-native-unistyles';
import { useState } from "react";
import { View, Text, Pressable } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
import type { AgentActivity, GroupedTextMessage, MergedToolCall, SessionUpdate } from '@/types/agent-activity';
import type {
AgentActivity,
GroupedTextMessage,
MergedToolCall,
SessionUpdate,
} from "@/types/agent-activity";
interface AgentActivityItemProps {
item: GroupedTextMessage | MergedToolCall | AgentActivity;
}
function formatTimestamp(date: Date): string {
return new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
return new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit",
second: "2-digit",
hour12: true,
}).format(date);
}
function getToolIcon(toolKind?: string): string {
switch (toolKind) {
case 'read':
return '📖';
case 'edit':
return '✏️';
case 'delete':
return '🗑️';
case 'move':
return '📦';
case 'search':
return '🔍';
case 'execute':
return '▶️';
case 'think':
return '💭';
case 'fetch':
return '🌐';
case 'switch_mode':
return '🔄';
case "read":
return "📖";
case "edit":
return "✏️";
case "delete":
return "🗑️";
case "move":
return "📦";
case "search":
return "🔍";
case "execute":
return "▶️";
case "think":
return "💭";
case "fetch":
return "🌐";
case "switch_mode":
return "🔄";
default:
return '🔧';
return "🔧";
}
}
function getStatusColor(status?: string): string {
switch (status) {
case 'pending':
return '#9ca3af';
case 'in_progress':
return '#fbbf24';
case 'completed':
return '#22c55e';
case 'failed':
return '#ef4444';
case "pending":
return "#9ca3af";
case "in_progress":
return "#fbbf24";
case "completed":
return "#22c55e";
case "failed":
return "#ef4444";
default:
return '#6b7280';
return "#6b7280";
}
}
function GroupedTextItem({ item }: { item: GroupedTextMessage }) {
const isThought = item.messageType === 'thought';
const isThought = item.messageType === "thought";
return (
<View style={[stylesheet.card, isThought && stylesheet.thoughtCard]}>
<Text style={[stylesheet.timestamp, isThought && stylesheet.thoughtTimestamp]}>
{formatTimestamp(item.startTimestamp)}
</Text>
{isThought && (
<Text style={stylesheet.thoughtLabel}>💭 Thinking</Text>
)}
<Text style={[stylesheet.text, isThought && stylesheet.thoughtText]}>
{item.text}
</Text>
{isThought && <Text style={stylesheet.thoughtLabel}>💭 Thinking</Text>}
<Text style={[stylesheet.text, isThought && stylesheet.thoughtText]}>{item.text}</Text>
</View>
);
}
@@ -80,28 +81,20 @@ function MergedToolCallItem({ item }: { item: MergedToolCall }) {
return (
<View style={stylesheet.toolCard}>
<Pressable
onPress={() => setIsExpanded(!isExpanded)}
style={stylesheet.toolHeader}
>
<Pressable onPress={() => setIsExpanded(!isExpanded)} style={stylesheet.toolHeader}>
<View style={stylesheet.toolHeaderLeft}>
<Text style={stylesheet.timestamp}>
{formatTimestamp(item.startTimestamp)}
</Text>
<Text style={stylesheet.timestamp}>{formatTimestamp(item.startTimestamp)}</Text>
<View style={stylesheet.toolTitleRow}>
<Text style={stylesheet.toolIcon}>{getToolIcon(item.toolKind)}</Text>
<Text style={stylesheet.toolTitle}>{item.title}</Text>
<View
style={[
stylesheet.statusBadge,
{ backgroundColor: getStatusColor(item.status) },
]}
style={[stylesheet.statusBadge, { backgroundColor: getStatusColor(item.status) }]}
>
<Text style={stylesheet.statusText}>{item.status}</Text>
</View>
</View>
</View>
<Text style={stylesheet.expandIcon}>{isExpanded ? '▼' : '▶'}</Text>
<Text style={stylesheet.expandIcon}>{isExpanded ? "▼" : "▶"}</Text>
</Pressable>
{isExpanded && (
@@ -109,23 +102,17 @@ function MergedToolCallItem({ item }: { item: MergedToolCall }) {
{item.input && (
<View style={stylesheet.section}>
<Text style={stylesheet.sectionTitle}>Input:</Text>
<Text style={stylesheet.code}>
{JSON.stringify(item.input, null, 2)}
</Text>
<Text style={stylesheet.code}>{JSON.stringify(item.input, null, 2)}</Text>
</View>
)}
{item.output && (
<View style={stylesheet.section}>
<Text style={stylesheet.sectionTitle}>Output:</Text>
<Text style={stylesheet.code}>
{JSON.stringify(item.output, null, 2)}
</Text>
<Text style={stylesheet.code}>{JSON.stringify(item.output, null, 2)}</Text>
</View>
)}
{!item.input && !item.output && (
<Text style={stylesheet.emptyText}>
No details available
</Text>
<Text style={stylesheet.emptyText}>No details available</Text>
)}
</View>
)}
@@ -136,36 +123,26 @@ function MergedToolCallItem({ item }: { item: MergedToolCall }) {
function PlanItem({ update, timestamp }: { update: SessionUpdate; timestamp: Date }) {
const [isExpanded, setIsExpanded] = useState(true);
if (update.kind !== 'plan') {
if (update.kind !== "plan") {
return null;
}
return (
<View style={stylesheet.planCard}>
<Pressable
onPress={() => setIsExpanded(!isExpanded)}
style={stylesheet.planHeader}
>
<Pressable onPress={() => setIsExpanded(!isExpanded)} style={stylesheet.planHeader}>
<View style={stylesheet.planHeaderLeft}>
<Text style={stylesheet.timestamp}>{formatTimestamp(timestamp)}</Text>
<Text style={stylesheet.planTitle}>
📋 Tasks ({update.entries.length})
</Text>
<Text style={stylesheet.planTitle}>📋 Tasks ({update.entries.length})</Text>
</View>
<Text style={stylesheet.expandIcon}>{isExpanded ? '▼' : '▶'}</Text>
<Text style={stylesheet.expandIcon}>{isExpanded ? "▼" : "▶"}</Text>
</Pressable>
{isExpanded && (
<View style={stylesheet.planContent}>
{update.entries.map((entry, idx) => (
<View key={idx} style={stylesheet.planEntry}>
<Text
style={[
stylesheet.planEntryStatus,
{ color: getStatusColor(entry.status) },
]}
>
{entry.status === 'completed' ? '✓' : entry.status === 'in_progress' ? '⏳' : '○'}
<Text style={[stylesheet.planEntryStatus, { color: getStatusColor(entry.status) }]}>
{entry.status === "completed" ? "✓" : entry.status === "in_progress" ? "⏳" : "○"}
</Text>
<Text style={stylesheet.planEntryText}>{entry.content}</Text>
</View>
@@ -181,10 +158,7 @@ function UnknownActivityItem({ update, timestamp }: { update: SessionUpdate; tim
return (
<View style={stylesheet.unknownCard}>
<Pressable
onPress={() => setShowDrawer(!showDrawer)}
style={stylesheet.unknownHeader}
>
<Pressable onPress={() => setShowDrawer(!showDrawer)} style={stylesheet.unknownHeader}>
<Text style={stylesheet.timestamp}>{formatTimestamp(timestamp)}</Text>
<View style={stylesheet.unknownBadge}>
<Text style={stylesheet.unknownBadgeText}>{update.kind}</Text>
@@ -193,9 +167,7 @@ function UnknownActivityItem({ update, timestamp }: { update: SessionUpdate; tim
{showDrawer && (
<View style={stylesheet.drawerContent}>
<Text style={stylesheet.code}>
{JSON.stringify(update, null, 2)}
</Text>
<Text style={stylesheet.code}>{JSON.stringify(update, null, 2)}</Text>
</View>
)}
</View>
@@ -204,12 +176,12 @@ function UnknownActivityItem({ update, timestamp }: { update: SessionUpdate; tim
export function AgentActivityItem({ item }: AgentActivityItemProps) {
// Grouped text message
if ('kind' in item && item.kind === 'grouped_text') {
if ("kind" in item && item.kind === "grouped_text") {
return <GroupedTextItem item={item} />;
}
// Merged tool call
if ('kind' in item && item.kind === 'merged_tool_call') {
if ("kind" in item && item.kind === "merged_tool_call") {
return <MergedToolCallItem item={item} />;
}
@@ -218,12 +190,12 @@ export function AgentActivityItem({ item }: AgentActivityItemProps) {
const update = activity.update;
// Tasks
if (update.kind === 'plan') {
if (update.kind === "plan") {
return <PlanItem update={update} timestamp={activity.timestamp} />;
}
// Available commands update
if (update.kind === 'available_commands_update') {
if (update.kind === "available_commands_update") {
return (
<View style={stylesheet.card}>
<Text style={stylesheet.timestamp}>{formatTimestamp(activity.timestamp)}</Text>
@@ -235,13 +207,11 @@ export function AgentActivityItem({ item }: AgentActivityItemProps) {
}
// Current mode update
if (update.kind === 'current_mode_update') {
if (update.kind === "current_mode_update") {
return (
<View style={stylesheet.card}>
<Text style={stylesheet.timestamp}>{formatTimestamp(activity.timestamp)}</Text>
<Text style={stylesheet.infoText}>
Mode changed to: {update.currentModeId}
</Text>
<Text style={stylesheet.infoText}>Mode changed to: {update.currentModeId}</Text>
</View>
);
}
@@ -284,26 +254,26 @@ const stylesheet = StyleSheet.create((theme) => ({
},
thoughtText: {
color: theme.colors.foregroundMuted,
fontStyle: 'italic',
fontStyle: "italic",
},
toolCard: {
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[2],
overflow: 'hidden',
overflow: "hidden",
},
toolHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
padding: theme.spacing[3],
},
toolHeaderLeft: {
flex: 1,
},
toolTitleRow: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
marginTop: theme.spacing[1],
},
@@ -355,18 +325,18 @@ const stylesheet = StyleSheet.create((theme) => ({
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontStyle: 'italic',
fontStyle: "italic",
},
planCard: {
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[2],
overflow: 'hidden',
overflow: "hidden",
},
planHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
padding: theme.spacing[3],
},
planHeaderLeft: {
@@ -384,8 +354,8 @@ const stylesheet = StyleSheet.create((theme) => ({
padding: theme.spacing[3],
},
planEntry: {
flexDirection: 'row',
alignItems: 'flex-start',
flexDirection: "row",
alignItems: "flex-start",
gap: theme.spacing[2],
marginBottom: theme.spacing[2],
},
@@ -406,7 +376,7 @@ const stylesheet = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[2],
overflow: 'hidden',
overflow: "hidden",
},
unknownHeader: {
padding: theme.spacing[3],
@@ -417,7 +387,7 @@ const stylesheet = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.md,
marginTop: theme.spacing[2],
alignSelf: 'flex-start',
alignSelf: "flex-start",
},
unknownBadgeText: {
color: theme.colors.foreground,
@@ -1,13 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ReactElement, ReactNode } from "react";
import {
View,
Text,
Pressable,
TextInput,
ActivityIndicator,
Platform,
} from "react-native";
import { View, Text, Pressable, TextInput, ActivityIndicator, Platform } from "react-native";
import type { StyleProp, ViewStyle, TextProps } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
@@ -17,7 +10,18 @@ import {
BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet";
import Animated from "react-native-reanimated";
import { ChevronDown, ChevronRight, Pencil, Check, X, Bot, Brain, ShieldCheck, ShieldAlert, ShieldOff } from "lucide-react-native";
import {
ChevronDown,
ChevronRight,
Pencil,
Check,
X,
Bot,
Brain,
ShieldCheck,
ShieldAlert,
ShieldOff,
} from "lucide-react-native";
import { theme as defaultTheme } from "@/styles/theme";
import type {
AgentMode,
@@ -107,19 +111,14 @@ export function DropdownField({
testID={testID}
style={[styles.dropdownControl, disabled && styles.dropdownControlDisabled]}
>
<Text
style={value ? styles.dropdownValue : styles.dropdownPlaceholder}
numberOfLines={1}
>
<Text style={value ? styles.dropdownValue : styles.dropdownPlaceholder} numberOfLines={1}>
{value || placeholder}
</Text>
<ChevronDown size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
</Pressable>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
{warningMessage ? <Text style={styles.warningText}>{warningMessage}</Text> : null}
{!errorMessage && helperText ? (
<Text style={styles.helperText}>{helperText}</Text>
) : null}
{!errorMessage && helperText ? <Text style={styles.helperText}>{helperText}</Text> : null}
</View>
);
}
@@ -176,7 +175,7 @@ export function SelectField({
onPress();
}
},
[getWebKey, onPress, preventWebDefault]
[getWebKey, onPress, preventWebDefault],
);
const normalizedValue = (value ?? "").trim();
@@ -184,9 +183,7 @@ export function SelectField({
const hasConcreteValue =
normalizedValue.length > 0 &&
(normalizedPlaceholder.length === 0 || normalizedValue !== normalizedPlaceholder);
const displayText = hasConcreteValue
? normalizedValue
: (normalizedPlaceholder || "Select...");
const displayText = hasConcreteValue ? normalizedValue : normalizedPlaceholder || "Select...";
return (
<View style={styles.selectFieldContainer}>
@@ -231,12 +228,7 @@ interface DropdownSheetProps {
}
function DropdownSheetBackground({ style }: BottomSheetBackgroundProps) {
return (
<Animated.View
pointerEvents="none"
style={[style, styles.bottomSheetBackground]}
/>
);
return <Animated.View pointerEvents="none" style={[style, styles.bottomSheetBackground]} />;
}
export function DropdownSheet({
@@ -267,19 +259,14 @@ export function DropdownSheet({
onClose();
}
},
[onClose]
[onClose],
);
const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
opacity={0.45}
/>
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
),
[]
[],
);
return (
@@ -450,16 +437,14 @@ export function FormSelectTrigger({
onPress();
}
},
[getWebKey, onPress, preventWebDefault]
[getWebKey, onPress, preventWebDefault],
);
const normalizedValue = (value ?? "").trim();
const normalizedPlaceholder = (placeholder ?? "").trim();
const hasConcreteValue =
normalizedValue.length > 0 &&
(normalizedPlaceholder.length === 0 || normalizedValue !== normalizedPlaceholder);
const displayText = hasConcreteValue
? normalizedValue
: (normalizedPlaceholder || "Select...");
const displayText = hasConcreteValue ? normalizedValue : normalizedPlaceholder || "Select...";
return (
<Pressable
@@ -481,9 +466,7 @@ export function FormSelectTrigger({
>
{icon ? <View style={styles.compactSelectLeading}>{icon}</View> : null}
<View style={styles.compactSelectValueContainer}>
{showLabel ? (
<Text style={styles.compactSelectLabel}>{label}</Text>
) : null}
{showLabel ? <Text style={styles.compactSelectLabel}>{label}</Text> : null}
{isLoading ? (
<ActivityIndicator size="small" color={defaultTheme.colors.foregroundMuted} />
) : (
@@ -540,7 +523,7 @@ export function AgentConfigRow({
id: def.id,
label: def.label,
})),
[providerDefinitions]
[providerDefinitions],
);
const modeSelectOptions: ComboSelectOption[] = useMemo(() => {
@@ -554,9 +537,7 @@ export function AgentConfigRow({
}, [modeOptions]);
const modelSelectOptions: ComboSelectOption[] = useMemo(() => {
const opts: ComboSelectOption[] = [
{ id: "", label: "Auto" },
];
const opts: ComboSelectOption[] = [{ id: "", label: "Auto" }];
for (const model of models) {
opts.push({
id: model.id,
@@ -572,7 +553,7 @@ export function AgentConfigRow({
id: option.id,
label: option.label,
})),
[thinkingOptions]
[thinkingOptions],
);
const effectiveSelectedMode = selectedMode || (modeOptions.length > 0 ? modeOptions[0]?.id : "");
@@ -609,7 +590,9 @@ export function AgentConfigRow({
disabled={disabled}
isLoading={isModelLoading}
onSelect={onSelectModel}
icon={<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
icon={
<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
}
showLabel={false}
testID="draft-model-select"
/>
@@ -638,7 +621,9 @@ export function AgentConfigRow({
placeholder="Select..."
disabled={disabled}
onSelect={onSelectThinkingOption}
icon={<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
icon={
<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
}
showLabel={false}
/>
</View>
@@ -664,7 +649,7 @@ export function AssistantDropdown({
const anchorRef = useRef<View>(null);
const selectedDefinition = providerDefinitions.find(
(definition) => definition.id === selectedProvider
(definition) => definition.id === selectedProvider,
);
const options = useMemo(
@@ -673,7 +658,7 @@ export function AssistantDropdown({
id: def.id,
label: def.label,
})),
[providerDefinitions]
[providerDefinitions],
);
const handleOpen = useCallback(() => setIsOpen(true), []);
@@ -720,9 +705,9 @@ export function PermissionsDropdown({
const hasOptions = modeOptions.length > 0;
const selectedModeLabel = hasOptions
? modeOptions.find((mode) => mode.id === selectedMode)?.label ??
? (modeOptions.find((mode) => mode.id === selectedMode)?.label ??
modeOptions[0]?.label ??
"Default"
"Default")
: "Automatic";
const options = useMemo(
@@ -732,7 +717,7 @@ export function PermissionsDropdown({
label: mode.label,
description: mode.description,
})),
[modeOptions]
[modeOptions],
);
const handleOpen = useCallback(() => {
@@ -751,9 +736,7 @@ export function PermissionsDropdown({
onPress={handleOpen}
disabled={disabled || !hasOptions}
helperText={
hasOptions
? undefined
: "This assistant does not expose selectable permissions."
hasOptions ? undefined : "This assistant does not expose selectable permissions."
}
controlRef={anchorRef}
/>
@@ -795,7 +778,7 @@ export function ModelDropdown({
const anchorRef = useRef<View>(null);
const selectedLabel = selectedModel
? models.find((model) => model.id === selectedModel)?.label ?? selectedModel
? (models.find((model) => model.id === selectedModel)?.label ?? selectedModel)
: "Automatic";
const placeholder = isLoading && models.length === 0 ? "Loading..." : "Automatic";
const helperText = error
@@ -834,7 +817,7 @@ export function ModelDropdown({
onSelect(id);
}
},
[onClear, onSelect]
[onClear, onSelect],
);
return (
@@ -882,7 +865,7 @@ export function WorkingDirectoryDropdown({
const options = useMemo(
() => suggestedPaths.map((path) => ({ id: path, label: path })),
[suggestedPaths]
[suggestedPaths],
);
const handleOpen = useCallback(() => setIsOpen(true), []);
@@ -957,9 +940,7 @@ export function ToggleRow({
</View>
<View style={styles.toggleTextContainer}>
<Text style={styles.toggleLabel}>{label}</Text>
{description ? (
<Text style={styles.helperText}>{description}</Text>
) : null}
{description ? <Text style={styles.helperText}>{description}</Text> : null}
</View>
</Pressable>
);
@@ -1052,9 +1033,7 @@ export function GitOptionsSection({
<View style={styles.gitOptionsContainer}>
<Pressable
testID="worktree-create-toggle"
onPress={() =>
onWorktreeModeChange(isCreateMode ? "none" : "create")
}
onPress={() => onWorktreeModeChange(isCreateMode ? "none" : "create")}
disabled={isLoading}
style={[styles.worktreeToggle, isLoading && styles.worktreeToggleDisabled]}
>
@@ -1077,9 +1056,7 @@ export function GitOptionsSection({
<Pressable
testID="worktree-attach-toggle"
onPress={() =>
onWorktreeModeChange(isAttachMode ? "none" : "attach")
}
onPress={() => onWorktreeModeChange(isAttachMode ? "none" : "attach")}
disabled={isLoading}
style={[styles.worktreeToggle, isLoading && styles.worktreeToggleDisabled]}
>
@@ -1143,8 +1120,15 @@ export function GitOptionsSection({
placeholderTextColor={defaultTheme.colors.foregroundMuted}
onSubmitEditing={handleConfirmEdit}
/>
<Pressable onPress={handleConfirmEdit} hitSlop={8} style={styles.baseBranchIconButton}>
<Check size={defaultTheme.iconSize.md} color={defaultTheme.colors.palette.green[500]} />
<Pressable
onPress={handleConfirmEdit}
hitSlop={8}
style={styles.baseBranchIconButton}
>
<Check
size={defaultTheme.iconSize.md}
color={defaultTheme.colors.palette.green[500]}
/>
</Pressable>
<Pressable onPress={handleCancelEdit} hitSlop={8} style={styles.baseBranchIconButton}>
<X size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
@@ -1159,17 +1143,11 @@ export function GitOptionsSection({
</View>
) : null}
{baseBranchError ? (
<Text style={styles.errorText}>{baseBranchError}</Text>
) : null}
{baseBranchError ? <Text style={styles.errorText}>{baseBranchError}</Text> : null}
{repoError ? (
<Text style={styles.errorText}>{repoError}</Text>
) : null}
{repoError ? <Text style={styles.errorText}>{repoError}</Text> : null}
{gitValidationError ? (
<Text style={styles.errorText}>{gitValidationError}</Text>
) : null}
{gitValidationError ? <Text style={styles.errorText}>{gitValidationError}</Text> : null}
</View>
);
}
@@ -1,31 +1,31 @@
import { describe, expect, it } from 'vitest'
import { resolveStatusControlMode } from './agent-input-area.status-controls'
import { describe, expect, it } from "vitest";
import { resolveStatusControlMode } from "./agent-input-area.status-controls";
describe('resolveStatusControlMode', () => {
it('uses ready mode when no controlled status controls are provided', () => {
expect(resolveStatusControlMode(undefined)).toBe('ready')
})
describe("resolveStatusControlMode", () => {
it("uses ready mode when no controlled status controls are provided", () => {
expect(resolveStatusControlMode(undefined)).toBe("ready");
});
it('uses draft mode when controlled status controls are provided', () => {
it("uses draft mode when controlled status controls are provided", () => {
expect(
resolveStatusControlMode({
providerDefinitions: [],
selectedProvider: 'codex',
selectedProvider: "codex",
onSelectProvider: () => undefined,
modeOptions: [],
selectedMode: '',
selectedMode: "",
onSelectMode: () => undefined,
models: [],
selectedModel: '',
selectedModel: "",
onSelectModel: () => undefined,
isModelLoading: false,
allProviderModels: new Map(),
isAllModelsLoading: false,
onSelectProviderAndModel: () => undefined,
thinkingOptions: [],
selectedThinkingOptionId: '',
selectedThinkingOptionId: "",
onSelectThinkingOption: () => undefined,
})
).toBe('draft')
})
})
}),
).toBe("draft");
});
});
@@ -1,5 +1,5 @@
import type { DraftAgentStatusBarProps } from './agent-status-bar'
import type { DraftAgentStatusBarProps } from "./agent-status-bar";
export function resolveStatusControlMode(statusControls?: DraftAgentStatusBarProps) {
return statusControls ? 'draft' : 'ready'
return statusControls ? "draft" : "ready";
}
File diff suppressed because it is too large Load Diff
+142 -143
View File
@@ -6,107 +6,107 @@ import {
RefreshControl,
FlatList,
type ListRenderItem,
} from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useCallback, useMemo, useState, type ReactElement } from 'react'
import { router } from 'expo-router'
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
import { formatTimeAgo } from '@/utils/time'
import { shortenPath } from '@/utils/shorten-path'
import { type AggregatedAgent } from '@/hooks/use-aggregated-agents'
import { useSessionStore } from '@/stores/session-store'
import { Archive } from 'lucide-react-native'
import { prepareWorkspaceTab } from '@/utils/workspace-navigation'
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useCallback, useMemo, useState, type ReactElement } from "react";
import { router } from "expo-router";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path";
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useSessionStore } from "@/stores/session-store";
import { Archive } from "lucide-react-native";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
interface AgentListProps {
agents: AggregatedAgent[]
showCheckoutInfo?: boolean
isRefreshing?: boolean
onRefresh?: () => void
selectedAgentId?: string
onAgentSelect?: () => void
listFooterComponent?: ReactElement | null
showAttentionIndicator?: boolean
agents: AggregatedAgent[];
showCheckoutInfo?: boolean;
isRefreshing?: boolean;
onRefresh?: () => void;
selectedAgentId?: string;
onAgentSelect?: () => void;
listFooterComponent?: ReactElement | null;
showAttentionIndicator?: boolean;
}
type FlatListItem =
| { type: 'header'; key: string; title: string }
| { type: 'agent'; key: string; agent: AggregatedAgent }
| { type: "header"; key: string; title: string }
| { type: "agent"; key: string; agent: AggregatedAgent };
function deriveDateSectionLabel(lastActivityAt: Date): string {
const now = new Date()
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const yesterdayStart = new Date(todayStart.getTime() - 24 * 60 * 60 * 1000)
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterdayStart = new Date(todayStart.getTime() - 24 * 60 * 60 * 1000);
const activityStart = new Date(
lastActivityAt.getFullYear(),
lastActivityAt.getMonth(),
lastActivityAt.getDate()
)
lastActivityAt.getDate(),
);
if (activityStart.getTime() >= todayStart.getTime()) {
return 'Today'
return "Today";
}
if (activityStart.getTime() >= yesterdayStart.getTime()) {
return 'Yesterday'
return "Yesterday";
}
const diffTime = todayStart.getTime() - activityStart.getTime()
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24))
const diffTime = todayStart.getTime() - activityStart.getTime();
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
if (diffDays <= 7) {
return 'This week'
return "This week";
}
if (diffDays <= 30) {
return 'This month'
return "This month";
}
return 'Older'
return "Older";
}
function formatStatusLabel(status: AggregatedAgent['status']): string {
function formatStatusLabel(status: AggregatedAgent["status"]): string {
switch (status) {
case 'initializing':
return 'Starting'
case 'idle':
return 'Idle'
case 'running':
return 'Running'
case 'error':
return 'Error'
case 'closed':
return 'Closed'
case "initializing":
return "Starting";
case "idle":
return "Idle";
case "running":
return "Running";
case "error":
return "Error";
case "closed":
return "Closed";
default:
return status
return status;
}
}
function SessionBadge({
label,
icon,
tone = 'neutral',
tone = "neutral",
}: {
label: string
icon?: ReactElement
tone?: 'neutral' | 'warning' | 'danger'
label: string;
icon?: ReactElement;
tone?: "neutral" | "warning" | "danger";
}) {
return (
<View
style={[
styles.badge,
tone === 'warning' && styles.badgeWarning,
tone === 'danger' && styles.badgeDanger,
tone === "warning" && styles.badgeWarning,
tone === "danger" && styles.badgeDanger,
]}
>
{icon}
<Text
style={[
styles.badgeText,
tone === 'warning' && styles.badgeTextWarning,
tone === 'danger' && styles.badgeTextDanger,
tone === "warning" && styles.badgeTextWarning,
tone === "danger" && styles.badgeTextDanger,
]}
>
{label}
</Text>
</View>
)
);
}
function SessionRow({
@@ -117,19 +117,19 @@ function SessionRow({
onPress,
onLongPress,
}: {
agent: AggregatedAgent
isMobile: boolean
selectedAgentId?: string
showAttentionIndicator: boolean
onPress: (agent: AggregatedAgent) => void
onLongPress: (agent: AggregatedAgent) => void
agent: AggregatedAgent;
isMobile: boolean;
selectedAgentId?: string;
showAttentionIndicator: boolean;
onPress: (agent: AggregatedAgent) => void;
onLongPress: (agent: AggregatedAgent) => void;
}) {
const { theme } = useUnistyles()
const timeAgo = formatTimeAgo(agent.lastActivityAt)
const agentKey = `${agent.serverId}:${agent.id}`
const isSelected = selectedAgentId === agentKey
const statusLabel = formatStatusLabel(agent.status)
const projectPath = shortenPath(agent.cwd)
const { theme } = useUnistyles();
const timeAgo = formatTimeAgo(agent.lastActivityAt);
const agentKey = `${agent.serverId}:${agent.id}`;
const isSelected = selectedAgentId === agentKey;
const statusLabel = formatStatusLabel(agent.status);
const projectPath = shortenPath(agent.cwd);
return (
<Pressable
@@ -149,7 +149,7 @@ function SessionRow({
style={[styles.sessionTitle, isSelected && styles.sessionTitleHighlighted]}
numberOfLines={1}
>
{agent.title || 'New session'}
{agent.title || "New session"}
</Text>
{agent.archivedAt ? (
<SessionBadge
@@ -199,10 +199,9 @@ function SessionRow({
</View>
) : null}
</Pressable>
)
);
}
export function AgentList({
agents,
isRefreshing = false,
@@ -212,88 +211,88 @@ export function AgentList({
listFooterComponent,
showAttentionIndicator = true,
}: AgentListProps) {
const { theme } = useUnistyles()
const insets = useSafeAreaInsets()
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null)
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const actionClient = useSessionStore((state) =>
actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null
)
actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null,
);
const isActionSheetVisible = actionAgent !== null
const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionClient)
const isActionSheetVisible = actionAgent !== null;
const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionClient);
const handleAgentPress = useCallback(
(agent: AggregatedAgent) => {
if (isActionSheetVisible) {
return
return;
}
const serverId = agent.serverId
const agentId = agent.id
const serverId = agent.serverId;
const agentId = agent.id;
onAgentSelect?.()
onAgentSelect?.();
const route = prepareWorkspaceTab({
serverId,
workspaceId: agent.cwd,
target: { kind: 'agent', agentId },
target: { kind: "agent", agentId },
pin: Boolean(agent.archivedAt),
})
router.navigate(route as any)
});
router.navigate(route as any);
},
[isActionSheetVisible, onAgentSelect]
)
[isActionSheetVisible, onAgentSelect],
);
const handleAgentLongPress = useCallback((agent: AggregatedAgent) => {
setActionAgent(agent)
}, [])
setActionAgent(agent);
}, []);
const handleCloseActionSheet = useCallback(() => {
setActionAgent(null)
}, [])
setActionAgent(null);
}, []);
const handleArchiveAgent = useCallback(() => {
if (!actionAgent || !actionClient) {
return
return;
}
void actionClient.archiveAgent(actionAgent.id)
setActionAgent(null)
}, [actionAgent, actionClient])
void actionClient.archiveAgent(actionAgent.id);
setActionAgent(null);
}, [actionAgent, actionClient]);
const flatItems = useMemo((): FlatListItem[] => {
const order = ['Today', 'Yesterday', 'This week', 'This month', 'Older'] as const
const buckets = new Map<string, AggregatedAgent[]>()
const order = ["Today", "Yesterday", "This week", "This month", "Older"] as const;
const buckets = new Map<string, AggregatedAgent[]>();
for (const agent of agents) {
const label = deriveDateSectionLabel(agent.lastActivityAt)
const existing = buckets.get(label) ?? []
existing.push(agent)
buckets.set(label, existing)
const label = deriveDateSectionLabel(agent.lastActivityAt);
const existing = buckets.get(label) ?? [];
existing.push(agent);
buckets.set(label, existing);
}
const result: FlatListItem[] = []
const result: FlatListItem[] = [];
for (const label of order) {
const data = buckets.get(label)
const data = buckets.get(label);
if (!data || data.length === 0) {
continue
continue;
}
result.push({ type: 'header', key: `header:${label}`, title: label })
result.push({ type: "header", key: `header:${label}`, title: label });
for (const agent of data) {
result.push({ type: 'agent', key: `${agent.serverId}:${agent.id}`, agent })
result.push({ type: "agent", key: `${agent.serverId}:${agent.id}`, agent });
}
}
return result
}, [agents])
return result;
}, [agents]);
const renderItem: ListRenderItem<FlatListItem> = useCallback(
({ item }) => {
if (item.type === 'header') {
if (item.type === "header") {
return (
<View style={styles.sectionHeading}>
<Text style={styles.sectionTitle}>{item.title}</Text>
</View>
)
);
}
return (
<SessionRow
@@ -304,12 +303,12 @@ export function AgentList({
onPress={handleAgentPress}
onLongPress={handleAgentLongPress}
/>
)
);
},
[handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId, showAttentionIndicator]
)
[handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId, showAttentionIndicator],
);
const keyExtractor = useCallback((item: FlatListItem) => item.key, [])
const keyExtractor = useCallback((item: FlatListItem) => item.key, []);
return (
<>
@@ -350,7 +349,7 @@ export function AgentList({
>
<View style={styles.sheetHandle} />
<Text style={styles.sheetTitle}>
{isActionDaemonUnavailable ? 'Host offline' : 'Archive this session?'}
{isActionDaemonUnavailable ? "Host offline" : "Archive this session?"}
</Text>
<View style={styles.sheetButtonRow}>
<Pressable
@@ -380,7 +379,7 @@ export function AgentList({
</View>
</Modal>
</>
)
);
}
const styles = StyleSheet.create((theme) => ({
@@ -399,8 +398,8 @@ const styles = StyleSheet.create((theme) => ({
},
sectionHeading: {
marginTop: theme.spacing[2],
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[3],
marginBottom: theme.spacing[2],
@@ -411,8 +410,8 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
},
row: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: {
@@ -429,15 +428,15 @@ const styles = StyleSheet.create((theme) => ({
minWidth: 0,
},
rowTitleRow: {
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
flexDirection: "row",
alignItems: "center",
flexWrap: "wrap",
gap: theme.spacing[2],
},
rowMetaRow: {
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
flexDirection: "row",
alignItems: "center",
flexWrap: "wrap",
gap: theme.spacing[1],
marginTop: 2,
},
@@ -456,7 +455,7 @@ const styles = StyleSheet.create((theme) => ({
sessionTitle: {
flexShrink: 1,
fontSize: theme.fontSize.sm,
fontWeight: '400',
fontWeight: "400",
color: theme.colors.foreground,
opacity: 0.86,
},
@@ -464,7 +463,7 @@ const styles = StyleSheet.create((theme) => ({
opacity: 1,
},
sessionMetaText: {
maxWidth: '100%',
maxWidth: "100%",
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
@@ -486,11 +485,11 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
flexShrink: 0,
width: 72,
textAlign: 'right' as const,
textAlign: "right" as const,
},
badge: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
@@ -498,10 +497,10 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface2,
},
badgeWarning: {
backgroundColor: 'rgba(245, 158, 11, 0.12)',
backgroundColor: "rgba(245, 158, 11, 0.12)",
},
badgeDanger: {
backgroundColor: 'rgba(239, 68, 68, 0.14)',
backgroundColor: "rgba(239, 68, 68, 0.14)",
},
badgeText: {
fontSize: theme.fontSize.xs,
@@ -516,26 +515,26 @@ const styles = StyleSheet.create((theme) => ({
},
sheetOverlay: {
flex: 1,
justifyContent: 'flex-end',
justifyContent: "flex-end",
},
sheetBackdrop: {
position: 'absolute',
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
backgroundColor: 'rgba(0,0,0,0.35)',
backgroundColor: "rgba(0,0,0,0.35)",
},
sheetContainer: {
backgroundColor: theme.colors.surface2,
borderTopLeftRadius: theme.borderRadius['2xl'],
borderTopRightRadius: theme.borderRadius['2xl'],
borderTopLeftRadius: theme.borderRadius["2xl"],
borderTopRightRadius: theme.borderRadius["2xl"],
paddingHorizontal: theme.spacing[6],
paddingTop: theme.spacing[4],
gap: theme.spacing[4],
},
sheetHandle: {
alignSelf: 'center',
alignSelf: "center",
width: 40,
height: 4,
borderRadius: theme.borderRadius.full,
@@ -546,18 +545,18 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
textAlign: 'center',
textAlign: "center",
},
sheetButtonRow: {
flexDirection: 'row',
flexDirection: "row",
gap: theme.spacing[3],
},
sheetButton: {
flex: 1,
borderRadius: theme.borderRadius.lg,
paddingVertical: theme.spacing[4],
alignItems: 'center',
justifyContent: 'center',
alignItems: "center",
justifyContent: "center",
},
sheetArchiveButton: {
backgroundColor: theme.colors.primary,
@@ -578,4 +577,4 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.semibold,
fontSize: theme.fontSize.base,
},
}))
}));
@@ -1,72 +1,72 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it } from "vitest";
import {
getStatusSelectorHint,
normalizeModelId,
resolveAgentModelSelection,
} from './agent-status-bar.utils'
} from "./agent-status-bar.utils";
describe('getStatusSelectorHint', () => {
it('explains what each editable status control does', () => {
expect(getStatusSelectorHint('thinking')).toBe('Thinking mode')
expect(getStatusSelectorHint('model')).toBe('Change model')
expect(getStatusSelectorHint('mode')).toBe('Change permission mode')
})
})
describe("getStatusSelectorHint", () => {
it("explains what each editable status control does", () => {
expect(getStatusSelectorHint("thinking")).toBe("Thinking mode");
expect(getStatusSelectorHint("model")).toBe("Change model");
expect(getStatusSelectorHint("mode")).toBe("Change permission mode");
});
});
describe('normalizeModelId', () => {
it('treats empty and default values as unset', () => {
expect(normalizeModelId('')).toBeNull()
expect(normalizeModelId(' default ')).toBeNull()
expect(normalizeModelId(undefined)).toBeNull()
})
describe("normalizeModelId", () => {
it("treats empty and default values as unset", () => {
expect(normalizeModelId("")).toBeNull();
expect(normalizeModelId(" default ")).toBeNull();
expect(normalizeModelId(undefined)).toBeNull();
});
it('returns trimmed model ids', () => {
expect(normalizeModelId(' gpt-5.1-codex ')).toBe('gpt-5.1-codex')
})
})
it("returns trimmed model ids", () => {
expect(normalizeModelId(" gpt-5.1-codex ")).toBe("gpt-5.1-codex");
});
});
describe('resolveAgentModelSelection', () => {
it('prefers runtime model over configured model', () => {
describe("resolveAgentModelSelection", () => {
it("prefers runtime model over configured model", () => {
const selection = resolveAgentModelSelection({
models: [
{
id: 'a',
provider: 'codex',
label: 'Model A',
thinkingOptions: [{ id: 'low', label: 'Low' }],
defaultThinkingOptionId: 'low',
id: "a",
provider: "codex",
label: "Model A",
thinkingOptions: [{ id: "low", label: "Low" }],
defaultThinkingOptionId: "low",
},
],
runtimeModelId: 'a',
configuredModelId: 'b',
runtimeModelId: "a",
configuredModelId: "b",
explicitThinkingOptionId: null,
})
});
expect(selection.activeModelId).toBe('a')
expect(selection.displayModel).toBe('Model A')
expect(selection.selectedThinkingId).toBe('low')
})
expect(selection.activeModelId).toBe("a");
expect(selection.displayModel).toBe("Model A");
expect(selection.selectedThinkingId).toBe("low");
});
it('uses explicit thinking option when provided', () => {
it("uses explicit thinking option when provided", () => {
const selection = resolveAgentModelSelection({
models: [
{
id: 'a',
provider: 'codex',
label: 'Model A',
id: "a",
provider: "codex",
label: "Model A",
thinkingOptions: [
{ id: 'low', label: 'Low' },
{ id: 'high', label: 'High' },
{ id: "low", label: "Low" },
{ id: "high", label: "High" },
],
defaultThinkingOptionId: 'low',
defaultThinkingOptionId: "low",
},
],
runtimeModelId: 'a',
runtimeModelId: "a",
configuredModelId: null,
explicitThinkingOptionId: 'high',
})
explicitThinkingOptionId: "high",
});
expect(selection.selectedThinkingId).toBe('high')
expect(selection.displayThinking).toBe('High')
})
})
expect(selection.selectedThinkingId).toBe("high");
expect(selection.displayThinking).toBe("High");
});
});
+242 -230
View File
@@ -1,129 +1,130 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import { View, Text, Platform, Pressable } from 'react-native'
import { StyleSheet, useUnistyles } from 'react-native-unistyles'
import {
Brain,
ChevronDown,
ShieldAlert,
ShieldCheck,
ShieldOff,
} from 'lucide-react-native'
import { getProviderIcon } from '@/components/provider-icons'
import { CombinedModelSelector } from '@/components/combined-model-selector'
import { useQuery } from '@tanstack/react-query'
import { useSessionStore } from '@/stores/session-store'
import { useCallback, useMemo, useRef, useState } from "react";
import { View, Text, Platform, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Brain, ChevronDown, ShieldAlert, ShieldCheck, ShieldOff } from "lucide-react-native";
import { getProviderIcon } from "@/components/provider-icons";
import { CombinedModelSelector } from "@/components/combined-model-selector";
import { useQuery } from "@tanstack/react-query";
import { useSessionStore } from "@/stores/session-store";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Combobox, ComboboxItem, type ComboboxOption } from '@/components/ui/combobox'
import { AdaptiveModalSheet } from '@/components/adaptive-modal-sheet'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
} from "@/components/ui/dropdown-menu";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type {
AgentMode,
AgentModelDefinition,
AgentProvider,
} from '@server/server/agent/agent-sdk-types'
import type { AgentProviderDefinition } from '@server/server/agent/provider-manifest'
} from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import {
getModeVisuals,
type AgentModeColorTier,
type AgentModeIcon,
} from '@server/server/agent/provider-manifest'
} from "@server/server/agent/provider-manifest";
import {
getStatusSelectorHint,
resolveAgentModelSelection,
} from '@/components/agent-status-bar.utils'
} from "@/components/agent-status-bar.utils";
type StatusOption = {
id: string
label: string
}
id: string;
label: string;
};
type StatusSelector = 'provider' | 'mode' | 'model' | 'thinking'
type StatusSelector = "provider" | "mode" | "model" | "thinking";
type ControlledAgentStatusBarProps = {
provider: string
providerOptions?: StatusOption[]
selectedProviderId?: string
onSelectProvider?: (providerId: string) => void
modeOptions?: StatusOption[]
selectedModeId?: string
onSelectMode?: (modeId: string) => void
modelOptions?: StatusOption[]
selectedModelId?: string
onSelectModel?: (modelId: string) => void
thinkingOptions?: StatusOption[]
selectedThinkingOptionId?: string
onSelectThinkingOption?: (thinkingOptionId: string) => void
disabled?: boolean
isModelLoading?: boolean
}
provider: string;
providerOptions?: StatusOption[];
selectedProviderId?: string;
onSelectProvider?: (providerId: string) => void;
modeOptions?: StatusOption[];
selectedModeId?: string;
onSelectMode?: (modeId: string) => void;
modelOptions?: StatusOption[];
selectedModelId?: string;
onSelectModel?: (modelId: string) => void;
thinkingOptions?: StatusOption[];
selectedThinkingOptionId?: string;
onSelectThinkingOption?: (thinkingOptionId: string) => void;
disabled?: boolean;
isModelLoading?: boolean;
};
export interface DraftAgentStatusBarProps {
providerDefinitions: AgentProviderDefinition[]
selectedProvider: AgentProvider
onSelectProvider: (provider: AgentProvider) => void
modeOptions: AgentMode[]
selectedMode: string
onSelectMode: (modeId: string) => void
models: AgentModelDefinition[]
selectedModel: string
onSelectModel: (modelId: string) => void
isModelLoading: boolean
allProviderModels: Map<string, AgentModelDefinition[]>
isAllModelsLoading: boolean
onSelectProviderAndModel: (provider: AgentProvider, modelId: string) => void
thinkingOptions: NonNullable<AgentModelDefinition['thinkingOptions']>
selectedThinkingOptionId: string
onSelectThinkingOption: (thinkingOptionId: string) => void
disabled?: boolean
providerDefinitions: AgentProviderDefinition[];
selectedProvider: AgentProvider;
onSelectProvider: (provider: AgentProvider) => void;
modeOptions: AgentMode[];
selectedMode: string;
onSelectMode: (modeId: string) => void;
models: AgentModelDefinition[];
selectedModel: string;
onSelectModel: (modelId: string) => void;
isModelLoading: boolean;
allProviderModels: Map<string, AgentModelDefinition[]>;
isAllModelsLoading: boolean;
onSelectProviderAndModel: (provider: AgentProvider, modelId: string) => void;
thinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
selectedThinkingOptionId: string;
onSelectThinkingOption: (thinkingOptionId: string) => void;
disabled?: boolean;
}
interface AgentStatusBarProps {
agentId: string
serverId: string
agentId: string;
serverId: string;
}
function findOptionLabel(options: StatusOption[] | undefined, selectedId: string | undefined, fallback: string) {
function findOptionLabel(
options: StatusOption[] | undefined,
selectedId: string | undefined,
fallback: string,
) {
if (!options || options.length === 0) {
return fallback
return fallback;
}
const selected = options.find((option) => option.id === selectedId)
return selected?.label ?? fallback
const selected = options.find((option) => option.id === selectedId);
return selected?.label ?? fallback;
}
const MODE_ICONS = {
ShieldCheck,
ShieldAlert,
ShieldOff,
} as const
} as const;
function getModeIconColor(
colorTier: AgentModeColorTier | undefined,
palette: { blue: { 500: string }; green: { 500: string }; amber: { 500: string }; red: { 500: string }; purple: { 500: string } }
palette: {
blue: { 500: string };
green: { 500: string };
amber: { 500: string };
red: { 500: string };
purple: { 500: string };
},
): string {
switch (colorTier) {
case 'default':
return palette.blue[500]
case 'safe':
return palette.green[500]
case 'moderate':
return palette.amber[500]
case 'dangerous':
return palette.red[500]
case 'readonly':
return palette.purple[500]
case "default":
return palette.blue[500];
case "safe":
return palette.green[500];
case "moderate":
return palette.amber[500];
case "dangerous":
return palette.red[500];
case "readonly":
return palette.purple[500];
default:
return palette.blue[500]
return palette.blue[500];
}
}
function ControlledStatusBar({
provider,
providerOptions,
@@ -141,71 +142,83 @@ function ControlledStatusBar({
disabled = false,
isModelLoading = false,
}: ControlledAgentStatusBarProps) {
const { theme } = useUnistyles()
const isWeb = Platform.OS === 'web'
const [prefsOpen, setPrefsOpen] = useState(false)
const [openSelector, setOpenSelector] = useState<StatusSelector | null>(null)
const { theme } = useUnistyles();
const isWeb = Platform.OS === "web";
const [prefsOpen, setPrefsOpen] = useState(false);
const [openSelector, setOpenSelector] = useState<StatusSelector | null>(null);
const providerAnchorRef = useRef<View>(null)
const modeAnchorRef = useRef<View>(null)
const modelAnchorRef = useRef<View>(null)
const thinkingAnchorRef = useRef<View>(null)
const providerAnchorRef = useRef<View>(null);
const modeAnchorRef = useRef<View>(null);
const modelAnchorRef = useRef<View>(null);
const thinkingAnchorRef = useRef<View>(null);
const canSelectProvider = Boolean(onSelectProvider && providerOptions && providerOptions.length > 0)
const canSelectMode = Boolean(onSelectMode && modeOptions && modeOptions.length > 0)
const canSelectModel = Boolean(onSelectModel)
const canSelectProvider = Boolean(
onSelectProvider && providerOptions && providerOptions.length > 0,
);
const canSelectMode = Boolean(onSelectMode && modeOptions && modeOptions.length > 0);
const canSelectModel = Boolean(onSelectModel);
const canSelectThinking = Boolean(
onSelectThinkingOption && thinkingOptions && thinkingOptions.length > 0
)
onSelectThinkingOption && thinkingOptions && thinkingOptions.length > 0,
);
const displayProvider = findOptionLabel(providerOptions, selectedProviderId, 'Provider')
const displayMode = findOptionLabel(modeOptions, selectedModeId, 'Default')
const displayProvider = findOptionLabel(providerOptions, selectedProviderId, "Provider");
const displayMode = findOptionLabel(modeOptions, selectedModeId, "Default");
const displayModel =
isModelLoading && (!modelOptions || modelOptions.length === 0)
? 'Loading models...'
: findOptionLabel(modelOptions, selectedModelId, 'Auto')
const displayThinking = findOptionLabel(thinkingOptions, selectedThinkingOptionId, 'auto')
? "Loading models..."
: findOptionLabel(modelOptions, selectedModelId, "Auto");
const displayThinking = findOptionLabel(thinkingOptions, selectedThinkingOptionId, "auto");
const modeVisuals = selectedModeId ? getModeVisuals(provider, selectedModeId) : undefined
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null
const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette)
const ProviderIcon = getProviderIcon(provider)
const modeVisuals = selectedModeId ? getModeVisuals(provider, selectedModeId) : undefined;
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null;
const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette);
const ProviderIcon = getProviderIcon(provider);
const hasAnyControl =
Boolean(providerOptions?.length) ||
Boolean(modeOptions?.length) ||
canSelectModel ||
Boolean(thinkingOptions?.length)
Boolean(thinkingOptions?.length);
if (!hasAnyControl) {
return null
return null;
}
const modelDisabled = disabled || isModelLoading || !modelOptions || modelOptions.length === 0
const modelDisabled = disabled || isModelLoading || !modelOptions || modelOptions.length === 0;
const SEARCH_THRESHOLD = 6
const SEARCH_THRESHOLD = 6;
const comboboxProviderOptions = useMemo<ComboboxOption[]>(
() => (providerOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
[providerOptions]
)
[providerOptions],
);
const comboboxModeOptions = useMemo<ComboboxOption[]>(
() => (modeOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
[modeOptions]
)
[modeOptions],
);
const comboboxModelOptions = useMemo<ComboboxOption[]>(
() => (modelOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
[modelOptions]
)
[modelOptions],
);
const comboboxThinkingOptions = useMemo<ComboboxOption[]>(
() => (thinkingOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
[thinkingOptions]
)
[thinkingOptions],
);
const renderModeOption = useCallback(
({ option, selected, active, onPress }: { option: ComboboxOption; selected: boolean; active: boolean; onPress: () => void }) => {
const visuals = getModeVisuals(provider, option.id)
const IconComponent = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck
({
option,
selected,
active,
onPress,
}: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}) => {
const visuals = getModeVisuals(provider, option.id);
const IconComponent = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck;
return (
<ComboboxItem
label={option.label}
@@ -214,24 +227,24 @@ function ControlledStatusBar({
onPress={onPress}
leadingSlot={<IconComponent size={16} color={theme.colors.foreground} />}
/>
)
);
},
[provider, theme.colors.foreground]
)
[provider, theme.colors.foreground],
);
const handleOpenChange = useCallback(
(selector: StatusSelector) => (nextOpen: boolean) => {
setOpenSelector(nextOpen ? selector : null)
setOpenSelector(nextOpen ? selector : null);
},
[]
)
[],
);
const handleSelectorPress = useCallback(
(selector: StatusSelector) => {
handleOpenChange(selector)(openSelector !== selector)
handleOpenChange(selector)(openSelector !== selector);
},
[handleOpenChange, openSelector]
)
[handleOpenChange, openSelector],
);
return (
<View style={styles.container}>
@@ -243,11 +256,11 @@ function ControlledStatusBar({
ref={providerAnchorRef}
collapsable={false}
disabled={disabled || !canSelectProvider}
onPress={() => handleSelectorPress('provider')}
onPress={() => handleSelectorPress("provider")}
style={({ pressed, hovered }) => [
styles.modeBadge,
hovered && styles.modeBadgeHovered,
(pressed || openSelector === 'provider') && styles.modeBadgePressed,
(pressed || openSelector === "provider") && styles.modeBadgePressed,
(disabled || !canSelectProvider) && styles.disabledBadge,
]}
accessibilityRole="button"
@@ -259,11 +272,11 @@ function ControlledStatusBar({
</Pressable>
<Combobox
options={comboboxProviderOptions}
value={selectedProviderId ?? ''}
value={selectedProviderId ?? ""}
onSelect={(id) => onSelectProvider?.(id)}
searchable={comboboxProviderOptions.length > SEARCH_THRESHOLD}
open={openSelector === 'provider'}
onOpenChange={handleOpenChange('provider')}
open={openSelector === "provider"}
onOpenChange={handleOpenChange("provider")}
anchorRef={providerAnchorRef}
desktopPlacement="top-start"
/>
@@ -273,7 +286,7 @@ function ControlledStatusBar({
{modeOptions && modeOptions.length > 0 ? (
<>
<Tooltip
key={`mode-${openSelector === 'mode' ? 'open' : 'closed'}`}
key={`mode-${openSelector === "mode" ? "open" : "closed"}`}
delayDuration={0}
enabledOnDesktop
enabledOnMobile={false}
@@ -283,11 +296,11 @@ function ControlledStatusBar({
ref={modeAnchorRef}
collapsable={false}
disabled={disabled || !canSelectMode}
onPress={() => handleSelectorPress('mode')}
onPress={() => handleSelectorPress("mode")}
style={({ pressed, hovered }) => [
styles.modeIconBadge,
hovered && styles.modeBadgeHovered,
(pressed || openSelector === 'mode') && styles.modeBadgePressed,
(pressed || openSelector === "mode") && styles.modeBadgePressed,
(disabled || !canSelectMode) && styles.disabledBadge,
]}
accessibilityRole="button"
@@ -302,16 +315,16 @@ function ControlledStatusBar({
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getStatusSelectorHint('mode')}</Text>
<Text style={styles.tooltipText}>{getStatusSelectorHint("mode")}</Text>
</TooltipContent>
</Tooltip>
<Combobox
options={comboboxModeOptions}
value={selectedModeId ?? ''}
value={selectedModeId ?? ""}
onSelect={(id) => onSelectMode?.(id)}
searchable={comboboxModeOptions.length > SEARCH_THRESHOLD}
open={openSelector === 'mode'}
onOpenChange={handleOpenChange('mode')}
open={openSelector === "mode"}
onOpenChange={handleOpenChange("mode")}
anchorRef={modeAnchorRef}
desktopPlacement="top-start"
renderOption={renderModeOption}
@@ -322,7 +335,7 @@ function ControlledStatusBar({
{canSelectModel ? (
<>
<Tooltip
key={`model-${openSelector === 'model' ? 'open' : 'closed'}`}
key={`model-${openSelector === "model" ? "open" : "closed"}`}
delayDuration={0}
enabledOnDesktop
enabledOnMobile={false}
@@ -332,11 +345,11 @@ function ControlledStatusBar({
ref={modelAnchorRef}
collapsable={false}
disabled={modelDisabled}
onPress={() => handleSelectorPress('model')}
onPress={() => handleSelectorPress("model")}
style={({ pressed, hovered }) => [
styles.modeBadge,
hovered && styles.modeBadgeHovered,
(pressed || openSelector === 'model') && styles.modeBadgePressed,
(pressed || openSelector === "model") && styles.modeBadgePressed,
modelDisabled && styles.disabledBadge,
]}
accessibilityRole="button"
@@ -349,16 +362,16 @@ function ControlledStatusBar({
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getStatusSelectorHint('model')}</Text>
<Text style={styles.tooltipText}>{getStatusSelectorHint("model")}</Text>
</TooltipContent>
</Tooltip>
<Combobox
options={comboboxModelOptions}
value={selectedModelId ?? ''}
value={selectedModelId ?? ""}
onSelect={(id) => onSelectModel?.(id)}
searchable={comboboxModelOptions.length > SEARCH_THRESHOLD}
open={openSelector === 'model'}
onOpenChange={handleOpenChange('model')}
open={openSelector === "model"}
onOpenChange={handleOpenChange("model")}
anchorRef={modelAnchorRef}
desktopPlacement="top-start"
/>
@@ -368,7 +381,7 @@ function ControlledStatusBar({
{thinkingOptions && thinkingOptions.length > 0 ? (
<>
<Tooltip
key={`thinking-${openSelector === 'thinking' ? 'open' : 'closed'}`}
key={`thinking-${openSelector === "thinking" ? "open" : "closed"}`}
delayDuration={0}
enabledOnDesktop
enabledOnMobile={false}
@@ -378,11 +391,11 @@ function ControlledStatusBar({
ref={thinkingAnchorRef}
collapsable={false}
disabled={disabled || !canSelectThinking}
onPress={() => handleSelectorPress('thinking')}
onPress={() => handleSelectorPress("thinking")}
style={({ pressed, hovered }) => [
styles.modeBadge,
hovered && styles.modeBadgeHovered,
(pressed || openSelector === 'thinking') && styles.modeBadgePressed,
(pressed || openSelector === "thinking") && styles.modeBadgePressed,
(disabled || !canSelectThinking) && styles.disabledBadge,
]}
accessibilityRole="button"
@@ -395,16 +408,16 @@ function ControlledStatusBar({
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getStatusSelectorHint('thinking')}</Text>
<Text style={styles.tooltipText}>{getStatusSelectorHint("thinking")}</Text>
</TooltipContent>
</Tooltip>
<Combobox
options={comboboxThinkingOptions}
value={selectedThinkingOptionId ?? ''}
value={selectedThinkingOptionId ?? ""}
onSelect={(id) => onSelectThinkingOption?.(id)}
searchable={comboboxThinkingOptions.length > SEARCH_THRESHOLD}
open={openSelector === 'thinking'}
onOpenChange={handleOpenChange('thinking')}
open={openSelector === "thinking"}
onOpenChange={handleOpenChange("thinking")}
anchorRef={thinkingAnchorRef}
desktopPlacement="top-start"
/>
@@ -415,16 +428,15 @@ function ControlledStatusBar({
<>
<Pressable
onPress={() => setPrefsOpen(true)}
style={({ pressed }) => [
styles.prefsButton,
pressed && styles.prefsButtonPressed,
]}
style={({ pressed }) => [styles.prefsButton, pressed && styles.prefsButtonPressed]}
accessibilityRole="button"
accessibilityLabel="Agent preferences"
testID="agent-preferences-button"
>
<ProviderIcon size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
<Text style={styles.prefsButtonText} numberOfLines={1}>{displayModel}</Text>
<Text style={styles.prefsButtonText} numberOfLines={1}>
{displayModel}
</Text>
</Pressable>
<AdaptiveModalSheet
@@ -436,8 +448,8 @@ function ControlledStatusBar({
{providerOptions && providerOptions.length > 0 ? (
<View style={styles.sheetSection}>
<DropdownMenu
open={openSelector === 'provider'}
onOpenChange={handleOpenChange('provider')}
open={openSelector === "provider"}
onOpenChange={handleOpenChange("provider")}
>
<DropdownMenuTrigger
disabled={disabled || !canSelectProvider}
@@ -471,8 +483,8 @@ function ControlledStatusBar({
{modeOptions && modeOptions.length > 0 ? (
<View style={styles.sheetSection}>
<DropdownMenu
open={openSelector === 'mode'}
onOpenChange={handleOpenChange('mode')}
open={openSelector === "mode"}
onOpenChange={handleOpenChange("mode")}
>
<DropdownMenuTrigger
disabled={disabled || !canSelectMode}
@@ -493,8 +505,8 @@ function ControlledStatusBar({
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{modeOptions.map((mode) => {
const visuals = getModeVisuals(provider, mode.id)
const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck
const visuals = getModeVisuals(provider, mode.id);
const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck;
return (
<DropdownMenuItem
key={mode.id}
@@ -504,7 +516,7 @@ function ControlledStatusBar({
>
{mode.label}
</DropdownMenuItem>
)
);
})}
</DropdownMenuContent>
</DropdownMenu>
@@ -514,8 +526,8 @@ function ControlledStatusBar({
{canSelectModel ? (
<View style={styles.sheetSection}>
<DropdownMenu
open={openSelector === 'model'}
onOpenChange={handleOpenChange('model')}
open={openSelector === "model"}
onOpenChange={handleOpenChange("model")}
>
<DropdownMenuTrigger
disabled={modelDisabled}
@@ -549,8 +561,8 @@ function ControlledStatusBar({
{thinkingOptions && thinkingOptions.length > 0 ? (
<View style={styles.sheetSection}>
<DropdownMenu
open={openSelector === 'thinking'}
onOpenChange={handleOpenChange('thinking')}
open={openSelector === "thinking"}
onOpenChange={handleOpenChange("thinking")}
>
<DropdownMenuTrigger
disabled={disabled || !canSelectThinking}
@@ -584,68 +596,68 @@ function ControlledStatusBar({
</>
)}
</View>
)
);
}
export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId))
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null)
const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId));
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const modelsQuery = useQuery({
queryKey: [
'providerModels',
"providerModels",
serverId,
agent?.provider ?? '__missing_provider__',
agent?.cwd ?? '__missing_cwd__',
agent?.provider ?? "__missing_provider__",
agent?.cwd ?? "__missing_cwd__",
],
enabled: Boolean(client && agent?.provider),
staleTime: 5 * 60 * 1000,
queryFn: async () => {
if (!client || !agent) {
throw new Error('Daemon client unavailable')
throw new Error("Daemon client unavailable");
}
const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd })
const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd });
if (payload.error) {
throw new Error(payload.error)
throw new Error(payload.error);
}
return payload.models ?? []
return payload.models ?? [];
},
})
});
const models = modelsQuery.data ?? null
const models = modelsQuery.data ?? null;
const displayMode =
agent?.availableModes?.find((mode) => mode.id === agent.currentModeId)?.label ||
agent?.currentModeId ||
'default'
"default";
const modelSelection = resolveAgentModelSelection({
models,
runtimeModelId: agent?.runtimeInfo?.model,
configuredModelId: agent?.model,
explicitThinkingOptionId: agent?.thinkingOptionId,
})
});
const modeOptions = useMemo<StatusOption[]>(() => {
return (agent?.availableModes ?? []).map((mode) => ({
id: mode.id,
label: mode.label,
}))
}, [agent?.availableModes])
}));
}, [agent?.availableModes]);
const modelOptions = useMemo<StatusOption[]>(() => {
return (models ?? []).map((model) => ({ id: model.id, label: model.label }))
}, [models])
return (models ?? []).map((model) => ({ id: model.id, label: model.label }));
}, [models]);
const thinkingOptions = useMemo<StatusOption[]>(() => {
return (modelSelection.thinkingOptions ?? []).map((option) => ({
id: option.id,
label: option.label,
}))
}, [modelSelection.thinkingOptions])
}));
}, [modelSelection.thinkingOptions]);
if (!agent) {
return null
return null;
}
return (
@@ -654,41 +666,41 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
modeOptions={
modeOptions.length > 0
? modeOptions
: [{ id: agent.currentModeId ?? '', label: displayMode }]
: [{ id: agent.currentModeId ?? "", label: displayMode }]
}
selectedModeId={agent.currentModeId ?? undefined}
onSelectMode={(modeId) => {
if (!client) {
return
return;
}
void client.setAgentMode(agentId, modeId).catch((error) => {
console.warn('[AgentStatusBar] setAgentMode failed', error)
})
console.warn("[AgentStatusBar] setAgentMode failed", error);
});
}}
modelOptions={modelOptions}
selectedModelId={modelSelection.activeModelId ?? undefined}
onSelectModel={(modelId) => {
if (!client) {
return
return;
}
void client.setAgentModel(agentId, modelId).catch((error) => {
console.warn('[AgentStatusBar] setAgentModel failed', error)
})
console.warn("[AgentStatusBar] setAgentModel failed", error);
});
}}
thinkingOptions={thinkingOptions.length > 1 ? thinkingOptions : undefined}
selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined}
onSelectThinkingOption={(thinkingOptionId) => {
if (!client) {
return
return;
}
void client.setAgentThinkingOption(agentId, thinkingOptionId).catch((error) => {
console.warn('[AgentStatusBar] setAgentThinkingOption failed', error)
})
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
});
}}
isModelLoading={modelsQuery.isPending || modelsQuery.isFetching}
disabled={!client}
/>
)
);
}
export function DraftAgentStatusBar({
@@ -710,25 +722,25 @@ export function DraftAgentStatusBar({
onSelectThinkingOption,
disabled = false,
}: DraftAgentStatusBarProps) {
const isWeb = Platform.OS === 'web'
const isWeb = Platform.OS === "web";
const mappedModeOptions = useMemo<StatusOption[]>(() => {
if (modeOptions.length === 0) {
return [{ id: '', label: 'Default' }]
return [{ id: "", label: "Default" }];
}
return modeOptions.map((mode) => ({
id: mode.id,
label: mode.label,
}))
}, [modeOptions])
}));
}, [modeOptions]);
const mappedThinkingOptions = useMemo<StatusOption[]>(() => {
return thinkingOptions.map((option) => ({ id: option.id, label: option.label }))
}, [thinkingOptions])
return thinkingOptions.map((option) => ({ id: option.id, label: option.label }));
}, [thinkingOptions]);
const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || ''
const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || "";
const effectiveSelectedThinkingOption =
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined;
if (isWeb) {
return (
@@ -753,17 +765,17 @@ export function DraftAgentStatusBar({
disabled={disabled}
/>
</View>
)
);
}
const providerOptions = providerDefinitions.map((definition) => ({
id: definition.id,
label: definition.label,
}))
}));
const modelOptions: StatusOption[] = [{ id: '', label: 'Auto' }]
const modelOptions: StatusOption[] = [{ id: "", label: "Auto" }];
for (const model of models) {
modelOptions.push({ id: model.id, label: model.label })
modelOptions.push({ id: model.id, label: model.label });
}
return (
@@ -784,30 +796,30 @@ export function DraftAgentStatusBar({
onSelectThinkingOption={onSelectThinkingOption}
disabled={disabled}
/>
)
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flexDirection: 'row',
alignItems: 'flex-end',
flexDirection: "row",
alignItems: "flex-end",
gap: theme.spacing[1],
},
modeBadge: {
height: 28,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'transparent',
flexDirection: "row",
alignItems: "center",
backgroundColor: "transparent",
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius['2xl'],
borderRadius: theme.borderRadius["2xl"],
},
modeIconBadge: {
width: 28,
height: 28,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'transparent',
alignItems: "center",
justifyContent: "center",
backgroundColor: "transparent",
borderRadius: theme.borderRadius.full,
},
modeBadgeHovered: {
@@ -831,11 +843,11 @@ const styles = StyleSheet.create((theme) => ({
},
prefsButton: {
height: 28,
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius['2xl'],
borderRadius: theme.borderRadius["2xl"],
},
prefsButtonPressed: {
backgroundColor: theme.colors.surface0,
@@ -850,9 +862,9 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
},
sheetSelect: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
@@ -873,4 +885,4 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
},
}))
}));
@@ -1,51 +1,54 @@
import type { AgentModelDefinition } from '@server/server/agent/agent-sdk-types'
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
export type ExplainedStatusSelector = 'mode' | 'model' | 'thinking'
export type ExplainedStatusSelector = "mode" | "model" | "thinking";
export function getStatusSelectorHint(selector: ExplainedStatusSelector): string {
switch (selector) {
case 'thinking':
return 'Thinking mode'
case 'model':
return 'Change model'
case 'mode':
return 'Change permission mode'
case "thinking":
return "Thinking mode";
case "model":
return "Change model";
case "mode":
return "Change permission mode";
}
}
export function normalizeModelId(modelId: string | null | undefined): string | null {
const normalized = typeof modelId === 'string' ? modelId.trim() : ''
if (!normalized || normalized.toLowerCase() === 'default') {
return null
const normalized = typeof modelId === "string" ? modelId.trim() : "";
if (!normalized || normalized.toLowerCase() === "default") {
return null;
}
return normalized
return normalized;
}
export function resolveAgentModelSelection(input: {
models: AgentModelDefinition[] | null
runtimeModelId: string | null | undefined
configuredModelId: string | null | undefined
explicitThinkingOptionId: string | null | undefined
models: AgentModelDefinition[] | null;
runtimeModelId: string | null | undefined;
configuredModelId: string | null | undefined;
explicitThinkingOptionId: string | null | undefined;
}) {
const { models, runtimeModelId, configuredModelId, explicitThinkingOptionId } = input
const normalizedRuntimeModelId = normalizeModelId(runtimeModelId)
const normalizedConfiguredModelId = normalizeModelId(configuredModelId)
const preferredModelId = normalizedRuntimeModelId ?? normalizedConfiguredModelId
const { models, runtimeModelId, configuredModelId, explicitThinkingOptionId } = input;
const normalizedRuntimeModelId = normalizeModelId(runtimeModelId);
const normalizedConfiguredModelId = normalizeModelId(configuredModelId);
const preferredModelId = normalizedRuntimeModelId ?? normalizedConfiguredModelId;
const selectedModel =
models && preferredModelId ? models.find((model) => model.id === preferredModelId) ?? null : null
models && preferredModelId
? (models.find((model) => model.id === preferredModelId) ?? null)
: null;
const activeModelId = selectedModel?.id ?? preferredModelId ?? null
const displayModel = selectedModel?.label ?? preferredModelId ?? 'Auto'
const activeModelId = selectedModel?.id ?? preferredModelId ?? null;
const displayModel = selectedModel?.label ?? preferredModelId ?? "Auto";
const thinkingOptions = selectedModel?.thinkingOptions ?? null
const thinkingOptions = selectedModel?.thinkingOptions ?? null;
const selectedThinkingId =
explicitThinkingOptionId && explicitThinkingOptionId !== 'default'
explicitThinkingOptionId && explicitThinkingOptionId !== "default"
? explicitThinkingOptionId
: selectedModel?.defaultThinkingOptionId ?? null
const selectedThinking = thinkingOptions?.find((option) => option.id === selectedThinkingId) ?? null
: (selectedModel?.defaultThinkingOptionId ?? null);
const selectedThinking =
thinkingOptions?.find((option) => option.id === selectedThinkingId) ?? null;
const displayThinking =
selectedThinking?.label ??
(selectedThinkingId === 'default' ? 'Model default' : selectedThinkingId ?? 'auto')
(selectedThinkingId === "default" ? "Model default" : (selectedThinkingId ?? "auto"));
return {
selectedModel,
@@ -54,5 +57,5 @@ export function resolveAgentModelSelection(input: {
thinkingOptions,
selectedThinkingId,
displayThinking,
}
};
}
@@ -128,7 +128,7 @@ function splitOrderedTail(params: {
}
export function buildAgentStreamRenderModel(
input: BuildAgentStreamRenderModelInput
input: BuildAgentStreamRenderModelInput,
): AgentStreamRenderModel {
const strategy = resolveStreamRenderStrategy({
platform: input.platform === "web" ? "web" : "native",
@@ -78,7 +78,7 @@ describe("resolveStreamRenderStrategy", () => {
resolveBottomAnchorTransportBehavior({
strategy,
isViewportSettling: true,
})
}),
).toEqual({
verificationDelayFrames: 4,
verificationRetryMode: "recheck",
@@ -95,7 +95,7 @@ describe("resolveStreamRenderStrategy", () => {
resolveBottomAnchorTransportBehavior({
strategy,
isViewportSettling: true,
})
}),
).toEqual({
verificationDelayFrames: 0,
verificationRetryMode: "rescroll",
@@ -172,7 +172,7 @@ describe("neighbor and traversal semantics", () => {
strategy: forward,
items: chronological,
startIndex: forwardStartIndex,
})
}),
).toBe("assistant-1\n\nassistant-2");
const inverted = resolveStreamRenderStrategy({
@@ -189,7 +189,7 @@ describe("neighbor and traversal semantics", () => {
strategy: inverted,
items: invertedItems,
startIndex: invertedStartIndex,
})
}),
).toBe("assistant-1\n\nassistant-2");
});
@@ -206,7 +206,7 @@ describe("neighbor and traversal semantics", () => {
items,
index: 0,
relation: "above",
})
}),
).toBeUndefined();
expect(
getStreamNeighborItem({
@@ -214,7 +214,7 @@ describe("neighbor and traversal semantics", () => {
items,
index: 0,
relation: "below",
})
}),
).toBeUndefined();
});
});
@@ -233,7 +233,7 @@ describe("scroll/bottom calculations", () => {
viewportHeight: 300,
contentHeight: 1000,
threshold: 24,
})
}),
).toBe(true);
expect(
isNearBottomForStreamRenderStrategy({
@@ -242,7 +242,7 @@ describe("scroll/bottom calculations", () => {
viewportHeight: 300,
contentHeight: 1000,
threshold: 24,
})
}),
).toBe(false);
});
@@ -259,7 +259,7 @@ describe("scroll/bottom calculations", () => {
viewportHeight: 300,
contentHeight: 1000,
threshold: 24,
})
}),
).toBe(true);
expect(
isNearBottomForStreamRenderStrategy({
@@ -268,14 +268,14 @@ describe("scroll/bottom calculations", () => {
viewportHeight: 300,
contentHeight: 1000,
threshold: 24,
})
}),
).toBe(false);
expect(
getBottomOffsetForStreamRenderStrategy({
strategy,
viewportHeight: 300,
contentHeight: 1000,
})
}),
).toBe(0);
});
@@ -290,7 +290,7 @@ describe("scroll/bottom calculations", () => {
strategy,
viewportHeight: 320,
contentHeight: 1000,
})
}),
).toBe(680);
});
});
File diff suppressed because it is too large Load Diff
@@ -62,7 +62,7 @@ describe("findMountedWindowStart", () => {
findMountedWindowStart({
items,
minMountedCount: 50,
})
}),
).toBe(0);
});
@@ -79,7 +79,7 @@ describe("findMountedWindowStart", () => {
findMountedWindowStart({
items,
minMountedCount: 50,
})
}),
).toBe(39);
});
});
@@ -136,20 +136,16 @@ describe("web virtualization test overrides", () => {
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: unknown;
__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: unknown;
};
const previousThreshold =
globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
const previousMounted =
globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS;
const previousThreshold = globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
const previousMounted = globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS;
try {
delete globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
delete globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS;
expect(getWebPartialVirtualizationThreshold()).toBe(
DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD
);
expect(getWebMountedRecentStreamItems()).toBe(
DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS
DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD,
);
expect(getWebMountedRecentStreamItems()).toBe(DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS);
globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 6;
globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS = 4;
@@ -159,14 +155,12 @@ describe("web virtualization test overrides", () => {
if (previousThreshold === undefined) {
delete globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
} else {
globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD =
previousThreshold;
globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = previousThreshold;
}
if (previousMounted === undefined) {
delete globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS;
} else {
globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS =
previousMounted;
globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS = previousMounted;
}
}
});
@@ -18,16 +18,14 @@ function readPositiveIntegerOverride(value: unknown): number | null {
export function getWebPartialVirtualizationThreshold(): number {
const override = readPositiveIntegerOverride(
(globalThis as BottomAnchorE2ETestGlobals)
.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD
(globalThis as BottomAnchorE2ETestGlobals).__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD,
);
return override ?? DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
}
export function getWebMountedRecentStreamItems(): number {
const override = readPositiveIntegerOverride(
(globalThis as BottomAnchorE2ETestGlobals)
.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS
(globalThis as BottomAnchorE2ETestGlobals).__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS,
);
return override ?? DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS;
}
@@ -1,35 +1,35 @@
import { useState } from 'react'
import { View, Text } from 'react-native'
import { StyleSheet } from 'react-native-unistyles'
import Animated from 'react-native-reanimated'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from '@/constants/layout'
import { useHostRuntimeClient, useHostRuntimeIsConnected } from '@/runtime/host-runtime'
import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style'
import { Button } from '@/components/ui/button'
import type { Theme } from '@/styles/theme'
import { useState } from "react";
import { View, Text } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import Animated from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { Button } from "@/components/ui/button";
import type { Theme } from "@/styles/theme";
interface ArchivedAgentCalloutProps {
serverId: string
agentId: string
serverId: string;
agentId: string;
}
export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCalloutProps) {
const insets = useSafeAreaInsets()
const client = useHostRuntimeClient(serverId)
const isConnected = useHostRuntimeIsConnected(serverId)
const [isUnarchiving, setIsUnarchiving] = useState(false)
const insets = useSafeAreaInsets();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const [isUnarchiving, setIsUnarchiving] = useState(false);
const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({ mode: 'translate' })
const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({ mode: "translate" });
async function handleUnarchive() {
if (!client || !isConnected || isUnarchiving) return
setIsUnarchiving(true)
if (!client || !isConnected || isUnarchiving) return;
setIsUnarchiving(true);
try {
await client.refreshAgent(agentId)
await client.refreshAgent(agentId);
} catch (error) {
console.error('[ArchivedAgentCallout] Failed to unarchive agent:', error)
setIsUnarchiving(false)
console.error("[ArchivedAgentCallout] Failed to unarchive agent:", error);
setIsUnarchiving(false);
}
}
@@ -53,36 +53,36 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
</View>
</View>
</Animated.View>
)
);
}
const styles = StyleSheet.create(((theme: Theme) => ({
container: {
flexDirection: 'column',
position: 'relative',
flexDirection: "column",
position: "relative",
},
inputAreaContainer: {
position: 'relative',
position: "relative",
minHeight: FOOTER_HEIGHT,
marginHorizontal: 'auto',
alignItems: 'center',
width: '100%',
overflow: 'visible',
marginHorizontal: "auto",
alignItems: "center",
width: "100%",
overflow: "visible",
padding: theme.spacing[4],
},
inputAreaContent: {
width: '100%',
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
},
callout: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
borderRadius: theme.borderRadius['2xl'],
borderRadius: theme.borderRadius["2xl"],
paddingVertical: {
xs: theme.spacing[3],
md: theme.spacing[4],
@@ -96,4 +96,4 @@ const styles = StyleSheet.create(((theme: Theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
})) as any) as Record<string, any>
})) as any) as Record<string, any>;
@@ -1,10 +1,4 @@
import {
View,
Text,
ScrollView,
Pressable,
Modal,
} from "react-native";
import { View, Text, ScrollView, Pressable, Modal } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { StyleSheet } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
@@ -185,15 +179,8 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
</Text>
</View>
<View style={styles.headerActions}>
<View
style={[
styles.badge,
typeBadgeStyles[artifact.type],
]}
>
<Text style={styles.badgeText}>
{artifact.type.toUpperCase()}
</Text>
<View style={[styles.badge, typeBadgeStyles[artifact.type]]}>
<Text style={styles.badgeText}>{artifact.type.toUpperCase()}</Text>
</View>
<Pressable onPress={onClose} style={styles.closeButton}>
<Text style={styles.closeButtonText}>×</Text>
@@ -208,12 +195,8 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
>
{artifact.type === "image" ? (
<View style={styles.imagePlaceholder}>
<Text style={styles.imagePlaceholderText}>
Image viewing not yet implemented
</Text>
<Text style={styles.imagePlaceholderSubtext}>
Base64 image data received
</Text>
<Text style={styles.imagePlaceholderText}>Image viewing not yet implemented</Text>
<Text style={styles.imagePlaceholderSubtext}>Base64 image data received</Text>
</View>
) : (
<View style={styles.codeContainer}>
@@ -244,9 +227,7 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
</View>
<View style={styles.metadataRow}>
<Text style={styles.metadataLabel}>Size:</Text>
<Text style={styles.metadataValue}>
{content.length.toLocaleString()} characters
</Text>
<Text style={styles.metadataValue}>{content.length.toLocaleString()} characters</Text>
</View>
</View>
</View>
@@ -49,7 +49,11 @@ function formatDuration(duration?: number): string | null {
return `${seconds.toFixed(seconds >= 10 ? 0 : 1)} s`;
}
export function AudioDebugNotice({ info, onDismiss, title = "Dictation Debug" }: AudioDebugNoticeProps) {
export function AudioDebugNotice({
info,
onDismiss,
title = "Dictation Debug",
}: AudioDebugNoticeProps) {
const { theme } = useUnistyles();
const [copied, setCopied] = useState(false);
@@ -115,11 +119,12 @@ export function AudioDebugNotice({ info, onDismiss, title = "Dictation Debug" }:
</View>
{info.debugRecordingPath ? (
<Pressable style={styles.pathRow} onPress={handleCopyPath} accessibilityLabel="Copy raw audio path">
<Text
numberOfLines={2}
style={[styles.pathText, { color: theme.colors.foreground }]}
>
<Pressable
style={styles.pathRow}
onPress={handleCopyPath}
accessibilityLabel="Copy raw audio path"
>
<Text numberOfLines={2} style={[styles.pathText, { color: theme.colors.foreground }]}>
{info.debugRecordingPath}
</Text>
<View style={[styles.copyPill, { backgroundColor: theme.colors.primary }]}>
@@ -137,9 +142,7 @@ export function AudioDebugNotice({ info, onDismiss, title = "Dictation Debug" }:
)}
{stats ? (
<Text style={[styles.stats, { color: theme.colors.foregroundMuted }]}>
{stats}
</Text>
<Text style={[styles.stats, { color: theme.colors.foregroundMuted }]}>{stats}</Text>
) : null}
</View>
);
@@ -1,24 +1,24 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import { View, Text, Pressable, Platform } from 'react-native'
import { StyleSheet, useUnistyles } from 'react-native-unistyles'
import { ArrowLeft, Check, ChevronDown, ChevronRight } from 'lucide-react-native'
import type { AgentModelDefinition, AgentProvider } from '@server/server/agent/agent-sdk-types'
import type { AgentProviderDefinition } from '@server/server/agent/provider-manifest'
import { Combobox, ComboboxItem, SearchInput } from '@/components/ui/combobox'
import { getProviderIcon } from '@/components/provider-icons'
import { useCallback, useMemo, useRef, useState } from "react";
import { View, Text, Pressable, Platform } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ArrowLeft, Check, ChevronDown, ChevronRight } from "lucide-react-native";
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import { Combobox, ComboboxItem, SearchInput } from "@/components/ui/combobox";
import { getProviderIcon } from "@/components/provider-icons";
const INLINE_MODEL_THRESHOLD = 8
const INLINE_MODEL_THRESHOLD = 8;
type DrillDownView = { provider: string }
type DrillDownView = { provider: string };
interface CombinedModelSelectorProps {
providerDefinitions: AgentProviderDefinition[]
allProviderModels: Map<string, AgentModelDefinition[]>
selectedProvider: string
selectedModel: string
onSelect: (provider: AgentProvider, modelId: string) => void
isLoading: boolean
disabled?: boolean
providerDefinitions: AgentProviderDefinition[];
allProviderModels: Map<string, AgentModelDefinition[]>;
selectedProvider: string;
selectedModel: string;
onSelect: (provider: AgentProvider, modelId: string) => void;
isLoading: boolean;
disabled?: boolean;
}
export function CombinedModelSelector({
@@ -30,43 +30,46 @@ export function CombinedModelSelector({
isLoading,
disabled = false,
}: CombinedModelSelectorProps) {
const { theme } = useUnistyles()
const anchorRef = useRef<View>(null)
const [isOpen, setIsOpen] = useState(false)
const [view, setView] = useState<'groups' | DrillDownView>('groups')
const [searchQuery, setSearchQuery] = useState('')
const { theme } = useUnistyles();
const anchorRef = useRef<View>(null);
const [isOpen, setIsOpen] = useState(false);
const [view, setView] = useState<"groups" | DrillDownView>("groups");
const [searchQuery, setSearchQuery] = useState("");
const handleOpenChange = useCallback((open: boolean) => {
setIsOpen(open)
if (open) {
const models = allProviderModels.get(selectedProvider)
if (models && models.length > INLINE_MODEL_THRESHOLD) {
setView({ provider: selectedProvider })
const handleOpenChange = useCallback(
(open: boolean) => {
setIsOpen(open);
if (open) {
const models = allProviderModels.get(selectedProvider);
if (models && models.length > INLINE_MODEL_THRESHOLD) {
setView({ provider: selectedProvider });
}
} else {
setView("groups");
setSearchQuery("");
}
} else {
setView('groups')
setSearchQuery('')
}
}, [allProviderModels, selectedProvider])
},
[allProviderModels, selectedProvider],
);
const handleSelect = useCallback(
(provider: string, modelId: string) => {
onSelect(provider as AgentProvider, modelId)
setIsOpen(false)
setView('groups')
setSearchQuery('')
onSelect(provider as AgentProvider, modelId);
setIsOpen(false);
setView("groups");
setSearchQuery("");
},
[onSelect]
)
[onSelect],
);
const ProviderIcon = getProviderIcon(selectedProvider)
const ProviderIcon = getProviderIcon(selectedProvider);
const selectedModelLabel = useMemo(() => {
const models = allProviderModels.get(selectedProvider)
if (!models) return isLoading ? 'Loading...' : 'Auto'
const model = models.find((m) => m.id === selectedModel)
return model?.label ?? 'Auto'
}, [allProviderModels, selectedProvider, selectedModel, isLoading])
const models = allProviderModels.get(selectedProvider);
if (!models) return isLoading ? "Loading..." : "Auto";
const model = models.find((m) => m.id === selectedModel);
return model?.label ?? "Auto";
}, [allProviderModels, selectedProvider, selectedModel, isLoading]);
return (
<>
@@ -99,7 +102,7 @@ export function CombinedModelSelector({
desktopPlacement="top-start"
title="Select model"
>
{view === 'groups' ? (
{view === "groups" ? (
<GroupsView
providerDefinitions={providerDefinitions}
allProviderModels={allProviderModels}
@@ -107,8 +110,8 @@ export function CombinedModelSelector({
selectedModel={selectedModel}
onSelect={handleSelect}
onDrillDown={(provider) => {
setView({ provider })
setSearchQuery('')
setView({ provider });
setSearchQuery("");
}}
/>
) : (
@@ -122,14 +125,14 @@ export function CombinedModelSelector({
onSearchChange={setSearchQuery}
onSelect={handleSelect}
onBack={() => {
setView('groups')
setSearchQuery('')
setView("groups");
setSearchQuery("");
}}
/>
)}
</Combobox>
</>
)
);
}
function GroupsView({
@@ -140,21 +143,21 @@ function GroupsView({
onSelect,
onDrillDown,
}: {
providerDefinitions: AgentProviderDefinition[]
allProviderModels: Map<string, AgentModelDefinition[]>
selectedProvider: string
selectedModel: string
onSelect: (provider: string, modelId: string) => void
onDrillDown: (provider: string) => void
providerDefinitions: AgentProviderDefinition[];
allProviderModels: Map<string, AgentModelDefinition[]>;
selectedProvider: string;
selectedModel: string;
onSelect: (provider: string, modelId: string) => void;
onDrillDown: (provider: string) => void;
}) {
const { theme } = useUnistyles()
const { theme } = useUnistyles();
return (
<View>
{providerDefinitions.map((def, index) => {
const models = allProviderModels.get(def.id) ?? []
const isInline = models.length <= INLINE_MODEL_THRESHOLD
const ProvIcon = getProviderIcon(def.id)
const models = allProviderModels.get(def.id) ?? [];
const isInline = models.length <= INLINE_MODEL_THRESHOLD;
const ProvIcon = getProviderIcon(def.id);
return (
<View key={def.id}>
@@ -193,10 +196,10 @@ function GroupsView({
</Pressable>
)}
</View>
)
);
})}
</View>
)
);
}
function DrillDownModelView({
@@ -210,27 +213,27 @@ function DrillDownModelView({
onSelect,
onBack,
}: {
provider: string
providerDefinitions: AgentProviderDefinition[]
models: AgentModelDefinition[]
selectedProvider: string
selectedModel: string
searchQuery: string
onSearchChange: (query: string) => void
onSelect: (provider: string, modelId: string) => void
onBack: () => void
provider: string;
providerDefinitions: AgentProviderDefinition[];
models: AgentModelDefinition[];
selectedProvider: string;
selectedModel: string;
searchQuery: string;
onSearchChange: (query: string) => void;
onSelect: (provider: string, modelId: string) => void;
onBack: () => void;
}) {
const { theme } = useUnistyles()
const ProvIcon = getProviderIcon(provider)
const providerLabel = providerDefinitions.find((d) => d.id === provider)?.label ?? provider
const { theme } = useUnistyles();
const ProvIcon = getProviderIcon(provider);
const providerLabel = providerDefinitions.find((d) => d.id === provider)?.label ?? provider;
const filteredModels = useMemo(() => {
if (!searchQuery.trim()) return models
const q = searchQuery.toLowerCase()
if (!searchQuery.trim()) return models;
const q = searchQuery.toLowerCase();
return models.filter(
(m) => m.label.toLowerCase().includes(q) || m.id.toLowerCase().includes(q)
)
}, [models, searchQuery])
(m) => m.label.toLowerCase().includes(q) || m.id.toLowerCase().includes(q),
);
}, [models, searchQuery]);
return (
<View>
@@ -251,7 +254,7 @@ function DrillDownModelView({
placeholder="Search models..."
value={searchQuery}
onChangeText={onSearchChange}
autoFocus={Platform.OS === 'web'}
autoFocus={Platform.OS === "web"}
/>
{filteredModels.map((model) => (
@@ -270,18 +273,18 @@ function DrillDownModelView({
</View>
) : null}
</View>
)
);
}
const styles = StyleSheet.create((theme) => ({
trigger: {
height: 28,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'transparent',
flexDirection: "row",
alignItems: "center",
backgroundColor: "transparent",
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius['2xl'],
borderRadius: theme.borderRadius["2xl"],
},
triggerHovered: {
backgroundColor: theme.colors.surface2,
@@ -303,8 +306,8 @@ const styles = StyleSheet.create((theme) => ({
marginVertical: theme.spacing[1],
},
sectionHeading: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
@@ -315,8 +318,8 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
},
drillDownRow: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
@@ -334,8 +337,8 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
},
drillDownTrailing: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
drillDownCount: {
@@ -343,8 +346,8 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
},
backButton: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
@@ -363,10 +366,10 @@ const styles = StyleSheet.create((theme) => ({
},
emptyState: {
paddingVertical: theme.spacing[4],
alignItems: 'center',
alignItems: "center",
},
emptyStateText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
}))
}));
+17 -40
View File
@@ -1,12 +1,4 @@
import {
Modal,
Pressable,
ScrollView,
Text,
TextInput,
View,
Platform,
} from "react-native";
import { Modal, Pressable, ScrollView, Text, TextInput, View, Platform } from "react-native";
import { memo, useEffect, useRef, type ReactNode } from "react";
import { Plus, Settings } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -54,16 +46,8 @@ const CommandCenterRow = memo(function CommandCenterRow({
export function CommandCenter() {
const { theme } = useUnistyles();
const {
open,
inputRef,
query,
setQuery,
activeIndex,
items,
handleClose,
handleSelectItem,
} = useCommandCenter();
const { open, inputRef, query, setQuery, activeIndex, items, handleClose, handleSelectItem } =
useCommandCenter();
const rowRefs = useRef<Map<number, View>>(new Map());
const resultsRef = useRef<ScrollView>(null);
@@ -77,11 +61,13 @@ export function CommandCenter() {
return;
}
const scrollNode =
(resultsRef.current as
| (ScrollView & {
getScrollableNode?: () => HTMLElement | null;
})
| null)?.getScrollableNode?.() ?? null;
(
resultsRef.current as
| (ScrollView & {
getScrollableNode?: () => HTMLElement | null;
})
| null
)?.getScrollableNode?.() ?? null;
const rowEl = row as unknown as HTMLElement;
if (!scrollNode) {
@@ -110,18 +96,16 @@ export function CommandCenter() {
const agentItems = items.filter((item) => item.kind === "agent");
return (
<Modal
visible={open}
transparent
animationType="fade"
onRequestClose={handleClose}
>
<Modal visible={open} transparent animationType="fade" onRequestClose={handleClose}>
<View style={styles.overlay}>
<Pressable style={styles.backdrop} onPress={handleClose} />
<View
testID="command-center-panel"
style={[styles.panel, { borderColor: theme.colors.border, backgroundColor: theme.colors.surface0 }]}
style={[
styles.panel,
{ borderColor: theme.colors.border, backgroundColor: theme.colors.surface0 },
]}
>
<View style={[styles.header, { borderBottomColor: theme.colors.border }]}>
<TextInput
@@ -131,10 +115,7 @@ export function CommandCenter() {
onChangeText={setQuery}
placeholder="Type a command or search agents..."
placeholderTextColor={theme.colors.foregroundMuted}
style={[
styles.input,
{ color: theme.colors.foreground },
]}
style={[styles.input, { color: theme.colors.foreground }]}
autoCapitalize="none"
autoCorrect={false}
autoFocus
@@ -164,11 +145,7 @@ export function CommandCenter() {
const action = item.action;
const actionIcon =
action.icon === "plus" ? (
<Plus
size={16}
strokeWidth={2.4}
color={theme.colors.foregroundMuted}
/>
<Plus size={16} strokeWidth={2.4} color={theme.colors.foregroundMuted} />
) : action.icon === "settings" ? (
<Settings
size={16}
@@ -40,18 +40,8 @@ export function ConnectionStatus({ isConnected }: ConnectionStatusProps) {
return (
<View style={styles.container}>
<View style={styles.row}>
<View
style={[
styles.dot,
isConnected ? styles.dotConnected : styles.dotDisconnected,
]}
/>
<Text
style={[
styles.text,
isConnected ? styles.textConnected : styles.textDisconnected,
]}
>
<View style={[styles.dot, isConnected ? styles.dotConnected : styles.dotDisconnected]} />
<Text style={[styles.text, isConnected ? styles.textConnected : styles.textDisconnected]}>
{isConnected ? "Connected" : "Disconnected"}
</Text>
</View>
@@ -24,9 +24,7 @@ interface DictationControlsProps {
function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, "0")}:${secs
.toString()
.padStart(2, "0")}`;
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
}
export function DictationControls({
@@ -66,12 +64,7 @@ export function DictationControls({
return (
<View style={styles.activeContainer}>
<View style={styles.meterWrapper}>
<VolumeMeter
volume={volume}
isMuted={false}
isSpeaking={false}
orientation="horizontal"
/>
<VolumeMeter volume={volume} isMuted={false} isSpeaking={false} orientation="horizontal" />
</View>
<Text style={[styles.timerText, { color: theme.colors.foreground }]}>
{formatDuration(duration)}
@@ -152,12 +145,7 @@ export function DictationOverlay({
}
return (
<View
style={[
overlayStyles.container,
{ backgroundColor: theme.colors.accent },
]}
>
<View style={[overlayStyles.container, { backgroundColor: theme.colors.accent }]}>
<Pressable
onPress={handleCancel}
disabled={actionsDisabled && !isFailed}
@@ -180,12 +168,7 @@ export function DictationOverlay({
orientation="horizontal"
color={theme.colors.palette.white}
/>
<Text
style={[
overlayStyles.timerText,
{ color: theme.colors.palette.white },
]}
>
<Text style={[overlayStyles.timerText, { color: theme.colors.palette.white }]}>
{formatDuration(duration)}
</Text>
</View>
@@ -205,26 +188,16 @@ export function DictationOverlay({
<View style={overlayStyles.actionButtonsContainer}>
{actionsDisabled ? (
<View style={overlayStyles.loadingContainer}>
<ActivityIndicator
size="small"
color={theme.colors.palette.white}
/>
<ActivityIndicator size="small" color={theme.colors.palette.white} />
</View>
) : isFailed ? (
<Pressable
onPress={onRetry}
accessibilityRole="button"
accessibilityLabel="Retry dictation"
style={[
overlayStyles.actionButton,
{ backgroundColor: theme.colors.palette.white },
]}
style={[overlayStyles.actionButton, { backgroundColor: theme.colors.palette.white }]}
>
<RefreshCcw
size={theme.iconSize.lg}
color={theme.colors.accent}
strokeWidth={2.5}
/>
<RefreshCcw size={theme.iconSize.lg} color={theme.colors.accent} strokeWidth={2.5} />
</Pressable>
) : (
<>
@@ -232,10 +205,7 @@ export function DictationOverlay({
onPress={onAccept}
accessibilityRole="button"
accessibilityLabel="Insert transcription"
style={[
overlayStyles.actionButton,
{ backgroundColor: "rgba(255, 255, 255, 0.25)" },
]}
style={[overlayStyles.actionButton, { backgroundColor: "rgba(255, 255, 255, 0.25)" }]}
>
<Pencil
size={theme.iconSize.lg}
@@ -247,16 +217,9 @@ export function DictationOverlay({
onPress={onAcceptAndSend}
accessibilityRole="button"
accessibilityLabel="Insert transcription and send"
style={[
overlayStyles.actionButton,
{ backgroundColor: theme.colors.palette.white },
]}
style={[overlayStyles.actionButton, { backgroundColor: theme.colors.palette.white }]}
>
<ArrowUp
size={theme.iconSize.lg}
color={theme.colors.accent}
strokeWidth={2.5}
/>
<ArrowUp size={theme.iconSize.lg} color={theme.colors.accent} strokeWidth={2.5} />
</Pressable>
</>
)}
@@ -1,6 +1,14 @@
import { View, Text, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AlertTriangle, CheckCircle2, Info, RefreshCcw, RotateCcw, WifiOff, X } from "lucide-react-native";
import {
AlertTriangle,
CheckCircle2,
Info,
RefreshCcw,
RotateCcw,
WifiOff,
X,
} from "lucide-react-native";
export type DictationToastVariant = "info" | "success" | "warning" | "error";
@@ -53,7 +61,8 @@ export function DictationStatusNotice({
})();
const foregroundColor = variant === "info" ? theme.colors.foreground : theme.colors.palette.white;
const secondaryColor = variant === "info" ? theme.colors.foregroundMuted : theme.colors.palette.white;
const secondaryColor =
variant === "info" ? theme.colors.foregroundMuted : theme.colors.palette.white;
return (
<View
@@ -83,11 +92,7 @@ export function DictationStatusNotice({
{(meta || (actionLabel && onAction)) && (
<View style={styles.actionsRow}>
{meta ? (
<Text style={[styles.meta, { color: secondaryColor }]}>{meta}</Text>
) : (
<View />
)}
{meta ? <Text style={[styles.meta, { color: secondaryColor }]}>{meta}</Text> : <View />}
{actionLabel && onAction ? (
<Pressable
style={[
+2 -1
View File
@@ -71,7 +71,8 @@ export function DiffViewer({
key={segIdx}
style={[
line.type === "add" ? styles.addText : styles.removeText,
segment.changed && (line.type === "add" ? styles.addHighlight : styles.removeHighlight),
segment.changed &&
(line.type === "add" ? styles.addHighlight : styles.removeHighlight),
]}
>
{segment.text}
@@ -41,7 +41,10 @@ export function DownloadToast() {
}
return (
<View style={[styles.container, { bottom: theme.spacing[4] + insets.bottom }]} pointerEvents="box-none">
<View
style={[styles.container, { bottom: theme.spacing[4] + insets.bottom }]}
pointerEvents="box-none"
>
<View style={styles.toast}>
{activeDownload.status === "downloading" ? (
<ActivityIndicator size="small" color={theme.colors.foreground} />
@@ -61,7 +64,7 @@ export function DownloadToast() {
: "Starting..."
: activeDownload.status === "complete"
? "Download complete"
: activeDownload.message ?? "Download failed"}
: (activeDownload.message ?? "Download failed")}
</Text>
{activeDownload.status === "downloading" && activeDownload.progress && (
<View style={styles.progressBar}>
@@ -5,10 +5,7 @@ import DraggableFlatList, {
type RenderItemParams,
} from "react-native-draggable-flatlist";
import { useUnistyles } from "react-native-unistyles";
import type {
DraggableListProps,
DraggableRenderItemInfo,
} from "./draggable-list.types";
import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types";
export type { DraggableListProps, DraggableRenderItemInfo };
@@ -53,7 +50,7 @@ export function DraggableList<T>({
};
return renderItem(info);
},
[renderItem]
[renderItem],
);
const handleDragEnd = useCallback(
@@ -61,7 +58,7 @@ export function DraggableList<T>({
setIsDragging(false);
onDragEnd(newData);
},
[onDragEnd]
[onDragEnd],
);
const handleDragBegin = useCallback(() => {
@@ -74,12 +71,11 @@ export function DraggableList<T>({
}, []);
const showRefreshControl = Boolean(onRefresh) && (!isDragging || Boolean(refreshing));
const resolvedContainerStyle =
containerStyle ?? (scrollEnabled ? { flex: 1 } : undefined);
const resolvedContainerStyle = containerStyle ?? (scrollEnabled ? { flex: 1 } : undefined);
const shouldShowRefreshControl = showRefreshControl && !nestable;
const ListComponent: typeof DraggableFlatList = (nestable
? (NestableDraggableFlatList as any)
: DraggableFlatList) as any;
const ListComponent: typeof DraggableFlatList = (
nestable ? (NestableDraggableFlatList as any) : DraggableFlatList
) as any;
return (
<ListComponent
@@ -19,14 +19,8 @@ import {
arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type {
DraggableListProps,
DraggableRenderItemInfo,
} from "./draggable-list.types";
import {
WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics,
} from "./web-desktop-scrollbar";
import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types";
import { WebDesktopScrollbarOverlay, useWebDesktopScrollbarMetrics } from "./web-desktop-scrollbar";
export type { DraggableListProps, DraggableRenderItemInfo };
@@ -79,7 +73,7 @@ function SortableItem<T>({
// the "ghost" stretches. Keep the dragged item's size stable by zeroing
// out the dnd-kit scaling component.
const baseTransform = CSS.Transform.toString(
transform && isDragging ? { ...transform, scaleX: 1, scaleY: 1 } : transform
transform && isDragging ? { ...transform, scaleX: 1, scaleY: 1 } : transform,
);
const scaleTransform = isDragging ? "scale(1.02)" : "";
const combinedTransform = [baseTransform, scaleTransform].filter(Boolean).join(" ");
@@ -100,9 +94,7 @@ function SortableItem<T>({
? {
attributes: attributes as unknown as Record<string, unknown>,
listeners: listeners as unknown as Record<string, unknown>,
setActivatorNodeRef: setActivatorNodeRef as unknown as (
node: unknown
) => void,
setActivatorNodeRef: setActivatorNodeRef as unknown as (node: unknown) => void,
}
: undefined,
};
@@ -152,7 +144,7 @@ export function DraggableList<T>({
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
}),
);
const handleDragStart = useCallback(
@@ -161,7 +153,7 @@ export function DraggableList<T>({
setActiveId(String(event.active.id));
onDragBegin?.();
},
[data, onDragBegin]
[data, onDragBegin],
);
const handleDragEnd = useCallback(
@@ -172,12 +164,8 @@ export function DraggableList<T>({
setDragItems(null);
if (over && active.id !== over.id) {
const oldIndex = items.findIndex(
(item, i) => keyExtractor(item, i) === active.id
);
const newIndex = items.findIndex(
(item, i) => keyExtractor(item, i) === over.id
);
const oldIndex = items.findIndex((item, i) => keyExtractor(item, i) === active.id);
const newIndex = items.findIndex((item, i) => keyExtractor(item, i) === over.id);
if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) {
const newItems = arrayMove(items, oldIndex, newIndex);
@@ -185,7 +173,7 @@ export function DraggableList<T>({
}
}
},
[items, keyExtractor, onDragEnd]
[items, keyExtractor, onDragEnd],
);
const ids = items.map((item, index) => keyExtractor(item, index));
@@ -204,9 +192,7 @@ export function DraggableList<T>({
testID={testID}
style={style}
contentContainerStyle={contentContainerStyle}
showsVerticalScrollIndicator={
showCustomScrollbar ? false : showsVerticalScrollIndicator
}
showsVerticalScrollIndicator={showCustomScrollbar ? false : showsVerticalScrollIndicator}
onLayout={showCustomScrollbar ? scrollbarMetrics.onLayout : undefined}
onContentSizeChange={
showCustomScrollbar ? scrollbarMetrics.onContentSizeChange : undefined
+1 -4
View File
@@ -21,10 +21,7 @@ export function EmptyState({ onCreateAgent, onImportAgent }: EmptyStateProps) {
<Text style={styles.primaryButtonText}>New agent</Text>
</Pressable>
{hasImportCta ? (
<Pressable
onPress={onImportAgent}
style={[styles.button, styles.secondaryButton]}
>
<Pressable onPress={onImportAgent} style={[styles.button, styles.secondaryButton]}>
<Download size={20} color={styles.secondaryButtonText.color} />
<Text style={styles.secondaryButtonText}>Import agent</Text>
</Pressable>
@@ -2,11 +2,7 @@ import { useCallback, useEffect, useMemo, useRef } from "react";
import { View, Text, Pressable, Platform, useWindowDimensions } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useIsFocused } from "@react-navigation/native";
import Animated, {
useAnimatedStyle,
useSharedValue,
runOnJS,
} from "react-native-reanimated";
import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { X } from "lucide-react-native";
@@ -23,8 +19,7 @@ import { FileExplorerPane } from "./file-explorer-pane";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
const MIN_CHAT_WIDTH = 400;
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {
}
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
interface ExplorerSidebarProps {
serverId: string;
@@ -44,8 +39,7 @@ export function ExplorerSidebar({
const { theme } = useUnistyles();
const isScreenFocused = useIsFocused();
const insets = useSafeAreaInsets();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
const closeToAgent = usePanelStore((state) => state.closeToAgent);
@@ -68,7 +62,7 @@ export function ExplorerSidebar({
}
const maxWidth = Math.max(
MIN_EXPLORER_SIDEBAR_WIDTH,
Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH)
Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH),
);
if (explorerWidth > maxWidth) {
setExplorerWidth(maxWidth);
@@ -102,7 +96,7 @@ export function ExplorerSidebar({
});
closeToAgent();
},
[closeToAgent, desktopFileExplorerOpen, isOpen, mobileView]
[closeToAgent, desktopFileExplorerOpen, isOpen, mobileView],
);
const enableSidebarCloseGesture = isMobile && isOpen;
@@ -111,7 +105,7 @@ export function ExplorerSidebar({
(tab: ExplorerTab) => {
setExplorerTabForCheckout({ serverId, cwd: workspaceRoot, isGit, tab });
},
[isGit, serverId, setExplorerTabForCheckout, workspaceRoot]
[isGit, serverId, setExplorerTabForCheckout, workspaceRoot],
);
// Swipe gesture to close (swipe right on mobile)
@@ -170,8 +164,7 @@ export function ExplorerSidebar({
})
.onEnd((event) => {
isGesturing.value = false;
const shouldClose =
event.translationX > windowWidth / 3 || event.velocityX > 500;
const shouldClose = event.translationX > windowWidth / 3 || event.velocityX > 500;
runOnJS(logExplorerSidebar)("closeGestureEnd", {
translationX: event.translationX,
velocityX: event.velocityX,
@@ -200,7 +193,7 @@ export function ExplorerSidebar({
closeGestureRef,
closeTouchStartX,
closeTouchStartY,
]
],
);
// Desktop resize gesture (drag left edge)
@@ -218,18 +211,15 @@ export function ExplorerSidebar({
const newWidth = startWidthRef.current - event.translationX;
const maxWidth = Math.max(
MIN_EXPLORER_SIDEBAR_WIDTH,
Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH)
);
const clampedWidth = Math.max(
MIN_EXPLORER_SIDEBAR_WIDTH,
Math.min(maxWidth, newWidth)
Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH),
);
const clampedWidth = Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(maxWidth, newWidth));
resizeWidth.value = clampedWidth;
})
.onEnd(() => {
runOnJS(setExplorerWidth)(resizeWidth.value);
}),
[isMobile, explorerWidth, resizeWidth, setExplorerWidth, viewportWidth]
[isMobile, explorerWidth, resizeWidth, setExplorerWidth, viewportWidth],
);
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
@@ -299,16 +289,11 @@ export function ExplorerSidebar({
}
return (
<Animated.View
style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }]}
>
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }]}>
{/* Resize handle - absolutely positioned over left border */}
<GestureDetector gesture={resizeGesture}>
<View
style={[
styles.resizeHandle,
Platform.OS === "web" && ({ cursor: "col-resize" } as any),
]}
style={[styles.resizeHandle, Platform.OS === "web" && ({ cursor: "col-resize" } as any)]}
/>
</GestureDetector>
@@ -351,8 +336,7 @@ function SidebarContent({
onOpenFile,
}: SidebarContentProps) {
const { theme } = useUnistyles();
const resolvedTab: ExplorerTab =
!isGit && activeTab === "changes" ? "files" : activeTab;
const resolvedTab: ExplorerTab = !isGit && activeTab === "changes" ? "files" : activeTab;
return (
<View style={styles.sidebarContent} pointerEvents="auto">
@@ -365,12 +349,7 @@ function SidebarContent({
style={[styles.tab, resolvedTab === "changes" && styles.tabActive]}
onPress={() => onTabPress("changes")}
>
<Text
style={[
styles.tabText,
resolvedTab === "changes" && styles.tabTextActive,
]}
>
<Text style={[styles.tabText, resolvedTab === "changes" && styles.tabTextActive]}>
Changes
</Text>
</Pressable>
@@ -380,12 +359,7 @@ function SidebarContent({
style={[styles.tab, resolvedTab === "files" && styles.tabActive]}
onPress={() => onTabPress("files")}
>
<Text
style={[
styles.tabText,
resolvedTab === "files" && styles.tabTextActive,
]}
>
<Text style={[styles.tabText, resolvedTab === "files" && styles.tabTextActive]}>
Files
</Text>
</Pressable>
+2 -10
View File
@@ -1,10 +1,6 @@
import { View, Text, Platform } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import Animated, {
useAnimatedStyle,
withTiming,
useSharedValue,
} from "react-native-reanimated";
import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-native-reanimated";
import { useEffect } from "react";
import { Upload } from "lucide-react-native";
import { useFileDropZone } from "@/hooks/use-file-drop-zone";
@@ -18,11 +14,7 @@ interface FileDropZoneProps {
const IS_WEB = Platform.OS === "web";
export function FileDropZone({
children,
onFilesDropped,
disabled = false,
}: FileDropZoneProps) {
export function FileDropZone({ children, onFilesDropped, disabled = false }: FileDropZoneProps) {
const { theme } = useUnistyles();
const { isDragging, containerRef } = useFileDropZone({
onFilesDropped,
@@ -38,10 +38,7 @@ import {
RotateCw,
X,
} from "lucide-react-native";
import type {
AgentFileExplorerState,
ExplorerEntry,
} from "@/stores/session-store";
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
import { useHosts } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { useDownloadStore } from "@/stores/download-store";
@@ -54,10 +51,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
import {
usePanelStore,
type SortOption,
} from "@/stores/panel-store";
import { usePanelStore, type SortOption } from "@/stores/panel-store";
import { formatTimeAgo } from "@/utils/time";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import {
@@ -102,36 +96,32 @@ export function FileExplorerPane({
onOpenFile,
}: FileExplorerPaneProps) {
const { theme } = useUnistyles();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const daemons = useHosts();
const daemonProfile = useMemo(
() => daemons.find((daemon) => daemon.serverId === serverId),
[daemons, serverId]
);
const normalizedWorkspaceRoot = useMemo(
() => workspaceRoot.trim(),
[workspaceRoot]
[daemons, serverId],
);
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
const workspaceStateKey = useMemo(
() =>
buildWorkspaceExplorerStateKey({
workspaceId,
workspaceRoot: normalizedWorkspaceRoot,
}),
[normalizedWorkspaceRoot, workspaceId]
[normalizedWorkspaceRoot, workspaceId],
);
const workspaceScopeId = useMemo(
() => workspaceId?.trim() || normalizedWorkspaceRoot,
[normalizedWorkspaceRoot, workspaceId]
[normalizedWorkspaceRoot, workspaceId],
);
const hasWorkspaceScope = Boolean(workspaceStateKey && normalizedWorkspaceRoot);
const explorerState = useSessionStore((state) =>
workspaceStateKey && state.sessions[serverId]
? state.sessions[serverId]?.fileExplorer.get(workspaceStateKey)
: undefined
: undefined,
);
const {
@@ -156,9 +146,9 @@ export function FileExplorerPane({
const isDirectoryLoading = useCallback(
(path: string) =>
Boolean(
isExplorerLoading && pendingRequest?.mode === "list" && pendingRequest?.path === path
isExplorerLoading && pendingRequest?.mode === "list" && pendingRequest?.path === path,
),
[isExplorerLoading, pendingRequest?.mode, pendingRequest?.path]
[isExplorerLoading, pendingRequest?.mode, pendingRequest?.path],
);
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set(["."]));
@@ -235,7 +225,7 @@ export function FileExplorerPane({
});
}
},
[directories, expandedPaths, hasWorkspaceScope, requestDirectoryListing]
[directories, expandedPaths, hasWorkspaceScope, requestDirectoryListing],
);
const handleOpenFile = useCallback(
@@ -246,7 +236,7 @@ export function FileExplorerPane({
selectExplorerEntry(entry.path);
onOpenFile?.(entry.path);
},
[hasWorkspaceScope, onOpenFile, selectExplorerEntry]
[hasWorkspaceScope, onOpenFile, selectExplorerEntry],
);
const handleEntryPress = useCallback(
@@ -257,7 +247,7 @@ export function FileExplorerPane({
}
handleOpenFile(entry);
},
[handleOpenFile, handleToggleDirectory]
[handleOpenFile, handleToggleDirectory],
);
const handleCopyPath = useCallback(
@@ -266,10 +256,10 @@ export function FileExplorerPane({
buildAbsoluteExplorerPath({
workspaceRoot: normalizedWorkspaceRoot,
entryPath: path,
})
}),
);
},
[normalizedWorkspaceRoot]
[normalizedWorkspaceRoot],
);
const startDownload = useDownloadStore((state) => state.startDownload);
@@ -288,13 +278,7 @@ export function FileExplorerPane({
requestFileDownloadToken: (targetPath) => requestFileDownloadToken(targetPath),
});
},
[
daemonProfile,
requestFileDownloadToken,
serverId,
startDownload,
workspaceScopeId,
]
[daemonProfile, requestFileDownloadToken, serverId, startDownload, workspaceScopeId],
);
const handleSortCycle = useCallback(() => {
@@ -320,7 +304,7 @@ export function FileExplorerPane({
requestDirectoryListing(path, {
recordHistory: false,
setCurrentPath: false,
})
}),
),
]);
return null;
@@ -342,7 +326,7 @@ export function FileExplorerPane({
easing: Easing.linear,
}),
-1,
false
false,
);
return;
}
@@ -366,7 +350,7 @@ export function FileExplorerPane({
if (finished) {
refreshIconRotation.value = 0;
}
}
},
);
}, [isRefreshFetching, refreshIconRotation]);
@@ -394,10 +378,7 @@ export function FileExplorerPane({
!directories.has(".") &&
Boolean(isExplorerLoading && pendingRequest?.mode === "list" && pendingRequest?.path === ".");
const showBackFromError = Boolean(error && selectedEntryPath);
const errorRecoveryPath = useMemo(
() => getErrorRecoveryPath(explorerState),
[explorerState]
);
const errorRecoveryPath = useMemo(() => getErrorRecoveryPath(explorerState), [explorerState]);
const renderTreeRow = useCallback(
({ item }: ListRenderItemInfo<TreeRow>) => {
@@ -495,7 +476,7 @@ export function FileExplorerPane({
selectedEntryPath,
theme.colors,
theme.spacing,
]
],
);
const handleBackFromError = useCallback(() => {
@@ -515,7 +496,7 @@ export function FileExplorerPane({
treeScrollbarMetrics.onScroll(event);
}
},
[showDesktopWebScrollbar, treeScrollbarMetrics]
[showDesktopWebScrollbar, treeScrollbarMetrics],
);
const handleTreeListLayout = useCallback(
@@ -524,7 +505,7 @@ export function FileExplorerPane({
treeScrollbarMetrics.onLayout(event);
}
},
[showDesktopWebScrollbar, treeScrollbarMetrics]
[showDesktopWebScrollbar, treeScrollbarMetrics],
);
if (!hasWorkspaceScope) {
@@ -536,9 +517,7 @@ export function FileExplorerPane({
}
return (
<View
style={styles.container}
>
<View style={styles.container}>
{error ? (
<View style={styles.centerState}>
<Text style={styles.errorText}>{error}</Text>
@@ -632,16 +611,7 @@ export function FileExplorerPane({
type EntryDisplayKind = "directory" | "image" | "text" | "other";
const IMAGE_EXTENSIONS = new Set([
"png",
"jpg",
"jpeg",
"gif",
"bmp",
"svg",
"webp",
"ico",
]);
const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "bmp", "svg", "webp", "ico"]);
const TEXT_EXTENSIONS = new Set([
"txt",
@@ -684,7 +654,7 @@ const TEXT_EXTENSIONS = new Set([
function renderEntryIcon(
kind: EntryDisplayKind,
colors: { foreground: string; primary: string; directoryOpen?: boolean }
colors: { foreground: string; primary: string; directoryOpen?: boolean },
) {
const color = colors.foreground;
switch (kind) {
@@ -783,7 +753,7 @@ function buildTreeRows({
sortOption,
path: entry.path,
depth: depth + 1,
})
}),
);
}
}
+87 -87
View File
@@ -1,5 +1,5 @@
import React, { useCallback, useMemo, useRef } from 'react'
import { useQuery } from '@tanstack/react-query'
import React, { useCallback, useMemo, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import {
ActivityIndicator,
Image as RNImage,
@@ -10,54 +10,54 @@ import {
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
} from 'react-native'
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
import { Fonts } from '@/constants/theme'
import { useSessionStore, type ExplorerFile } from '@/stores/session-store'
} from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
import {
WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics,
} from '@/components/web-desktop-scrollbar'
} from "@/components/web-desktop-scrollbar";
import {
highlightCode,
darkHighlightColors,
lightHighlightColors,
type HighlightToken,
type HighlightStyle,
} from '@getpaseo/highlight'
} from "@getpaseo/highlight";
interface CodeLineProps {
tokens: HighlightToken[]
lineNumber: number
gutterWidth: number
colorMap: Record<HighlightStyle, string>
baseColor: string
tokens: HighlightToken[];
lineNumber: number;
gutterWidth: number;
colorMap: Record<HighlightStyle, string>;
baseColor: string;
}
interface FilePreviewBodyProps {
preview: ExplorerFile | null
isLoading: boolean
showDesktopWebScrollbar: boolean
isMobile: boolean
filePath: string
preview: ExplorerFile | null;
isLoading: boolean;
showDesktopWebScrollbar: boolean;
isMobile: boolean;
filePath: string;
}
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== 'string') {
return null
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function formatFileSize({ size }: { size: number }): string {
if (size < 1024) {
return `${size} B`
return `${size} B`;
}
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)} KB`
return `${(size / 1024).toFixed(1)} KB`;
}
return `${(size / (1024 * 1024)).toFixed(1)} MB`
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
const CodeLine = React.memo(function CodeLine({
@@ -76,22 +76,22 @@ const CodeLine = React.memo(function CodeLine({
{tokens.map((token, index) => (
<Text
key={index}
style={{ color: token.style ? colorMap[token.style] ?? baseColor : baseColor }}
style={{ color: token.style ? (colorMap[token.style] ?? baseColor) : baseColor }}
>
{token.text}
</Text>
))}
</Text>
</View>
)
})
);
});
const codeLineStyles = StyleSheet.create((theme) => ({
line: {
flexDirection: 'row',
flexDirection: "row",
},
gutter: {
alignItems: 'flex-end',
alignItems: "flex-end",
paddingRight: theme.spacing[3],
flexShrink: 0,
},
@@ -107,7 +107,7 @@ const codeLineStyles = StyleSheet.create((theme) => ({
lineHeight: theme.fontSize.sm * 1.45,
flex: 1,
},
}))
}));
function FilePreviewBody({
preview,
@@ -116,46 +116,46 @@ function FilePreviewBody({
isMobile,
filePath,
}: FilePreviewBodyProps) {
const { theme } = useUnistyles()
const isDark = theme.colors.surface0 === '#18181c'
const colorMap = isDark ? darkHighlightColors : lightHighlightColors
const baseColor = isDark ? '#c9d1d9' : '#24292f'
const { theme } = useUnistyles();
const isDark = theme.colors.surface0 === "#18181c";
const colorMap = isDark ? darkHighlightColors : lightHighlightColors;
const baseColor = isDark ? "#c9d1d9" : "#24292f";
const enablePreviewDesktopScrollbar = showDesktopWebScrollbar
const previewScrollRef = useRef<RNScrollView>(null)
const previewScrollbarMetrics = useWebDesktopScrollbarMetrics()
const enablePreviewDesktopScrollbar = showDesktopWebScrollbar;
const previewScrollRef = useRef<RNScrollView>(null);
const previewScrollbarMetrics = useWebDesktopScrollbarMetrics();
const highlightedLines = useMemo(() => {
if (!preview || preview.kind !== 'text') {
return null
if (!preview || preview.kind !== "text") {
return null;
}
return highlightCode(preview.content ?? '', filePath)
}, [preview?.kind, preview?.content, filePath])
return highlightCode(preview.content ?? "", filePath);
}, [preview?.kind, preview?.content, filePath]);
const gutterWidth = useMemo(() => {
if (!highlightedLines) return 0
const digits = String(highlightedLines.length).length
return Math.max(digits * 8 + 12, 28)
}, [highlightedLines])
if (!highlightedLines) return 0;
const digits = String(highlightedLines.length).length;
return Math.max(digits * 8 + 12, 28);
}, [highlightedLines]);
const handlePreviewScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
if (enablePreviewDesktopScrollbar) {
previewScrollbarMetrics.onScroll(event)
previewScrollbarMetrics.onScroll(event);
}
},
[enablePreviewDesktopScrollbar, previewScrollbarMetrics]
)
[enablePreviewDesktopScrollbar, previewScrollbarMetrics],
);
const handlePreviewLayout = useCallback(
(event: LayoutChangeEvent) => {
if (enablePreviewDesktopScrollbar) {
previewScrollbarMetrics.onLayout(event)
previewScrollbarMetrics.onLayout(event);
}
},
[enablePreviewDesktopScrollbar, previewScrollbarMetrics]
)
[enablePreviewDesktopScrollbar, previewScrollbarMetrics],
);
if (isLoading && !preview) {
return (
@@ -163,7 +163,7 @@ function FilePreviewBody({
<ActivityIndicator size="small" />
<Text style={styles.loadingText}>Loading file…</Text>
</View>
)
);
}
if (!preview) {
@@ -171,11 +171,11 @@ function FilePreviewBody({
<View style={styles.centerState}>
<Text style={styles.emptyText}>No preview available</Text>
</View>
)
);
}
if (preview.kind === 'text') {
const lines = highlightedLines ?? [[{ text: preview.content ?? '', style: null }]]
if (preview.kind === "text") {
const lines = highlightedLines ?? [[{ text: preview.content ?? "", style: null }]];
const codeLines = (
<View>
{lines.map((tokens, index) => (
@@ -189,7 +189,7 @@ function FilePreviewBody({
/>
))}
</View>
)
);
return (
<View style={styles.previewScrollContainer}>
@@ -221,14 +221,14 @@ function FilePreviewBody({
enabled={enablePreviewDesktopScrollbar}
metrics={previewScrollbarMetrics}
onScrollToOffset={(nextOffset) => {
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false })
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
}}
/>
</View>
)
);
}
if (preview.kind === 'image' && preview.content) {
if (preview.kind === "image" && preview.content) {
return (
<View style={styles.previewScrollContainer}>
<RNScrollView
@@ -245,7 +245,7 @@ function FilePreviewBody({
>
<RNImage
source={{
uri: `data:${preview.mimeType ?? 'image/png'};base64,${preview.content}`,
uri: `data:${preview.mimeType ?? "image/png"};base64,${preview.content}`,
}}
style={styles.previewImage}
resizeMode="contain"
@@ -255,11 +255,11 @@ function FilePreviewBody({
enabled={enablePreviewDesktopScrollbar}
metrics={previewScrollbarMetrics}
onScrollToOffset={(nextOffset) => {
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false })
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
}}
/>
</View>
)
);
}
return (
@@ -267,7 +267,7 @@ function FilePreviewBody({
<Text style={styles.emptyText}>Binary preview unavailable</Text>
<Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
</View>
)
);
}
export function FilePane({
@@ -275,33 +275,33 @@ export function FilePane({
workspaceRoot,
filePath,
}: {
serverId: string
workspaceRoot: string
filePath: string
serverId: string;
workspaceRoot: string;
filePath: string;
}) {
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
const showDesktopWebScrollbar = Platform.OS === 'web' && !isMobile
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null)
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot])
const normalizedFilePath = useMemo(() => trimNonEmpty(filePath), [filePath])
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
const normalizedFilePath = useMemo(() => trimNonEmpty(filePath), [filePath]);
const query = useQuery({
queryKey: ['workspaceFile', serverId, normalizedWorkspaceRoot, normalizedFilePath],
queryKey: ["workspaceFile", serverId, normalizedWorkspaceRoot, normalizedFilePath],
enabled: Boolean(client && normalizedWorkspaceRoot && normalizedFilePath),
queryFn: async () => {
if (!client || !normalizedWorkspaceRoot || !normalizedFilePath) {
return { file: null as ExplorerFile | null, error: 'Host is not connected' }
return { file: null as ExplorerFile | null, error: "Host is not connected" };
}
const payload = await client.exploreFileSystem(
normalizedWorkspaceRoot,
normalizedFilePath,
'file'
)
return { file: payload.file ?? null, error: payload.error ?? null }
"file",
);
return { file: payload.file ?? null, error: payload.error ?? null };
},
staleTime: 5_000,
})
});
return (
<View style={styles.container} testID="workspace-file-pane">
@@ -319,7 +319,7 @@ export function FilePane({
filePath={filePath}
/>
</View>
)
);
}
const styles = StyleSheet.create((theme) => ({
@@ -330,8 +330,8 @@ const styles = StyleSheet.create((theme) => ({
},
centerState: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
alignItems: "center",
justifyContent: "center",
padding: theme.spacing[4],
},
loadingText: {
@@ -342,12 +342,12 @@ const styles = StyleSheet.create((theme) => ({
errorText: {
color: theme.colors.destructive,
fontSize: theme.fontSize.sm,
textAlign: 'center',
textAlign: "center",
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
textAlign: 'center',
textAlign: "center",
},
binaryMetaText: {
marginTop: theme.spacing[2],
@@ -368,11 +368,11 @@ const styles = StyleSheet.create((theme) => ({
previewImageScrollContent: {
flexGrow: 1,
padding: theme.spacing[4],
alignItems: 'center',
justifyContent: 'center',
alignItems: "center",
justifyContent: "center",
},
previewImage: {
width: '100%',
width: "100%",
height: 420,
},
}))
}));
@@ -2,9 +2,7 @@ import { describe, expect, it } from "vitest";
import { buildGitActions, type BuildGitActionsInput } from "./git-actions-policy";
function createInput(
overrides: Partial<BuildGitActionsInput> = {}
): BuildGitActionsInput {
function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitActionsInput {
return {
isGit: true,
githubFeaturesEnabled: true,
@@ -68,7 +66,7 @@ describe("git-actions-policy", () => {
aheadCount: 3,
aheadOfOrigin: 2,
shipDefault: "pr",
})
}),
);
expect(noPrActions.primary).toBeNull();
@@ -117,18 +115,20 @@ describe("git-actions-policy", () => {
hasRemote: true,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
})
}),
);
expect(actions.primary?.id).toBe("pr");
expect(actions.secondary.some((action) => action.id === "pr" && action.label === "View PR")).toBe(true);
expect(
actions.secondary.some((action) => action.id === "pr" && action.label === "View PR"),
).toBe(true);
});
it("disables sync on the base branch when already up to date", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
})
}),
);
const syncAction = actions.secondary.find((action) => action.id === "merge-from-base");
@@ -54,12 +54,7 @@ export interface BuildGitActionsInput {
runtime: Record<GitActionId, GitActionRuntimeState>;
}
const SECONDARY_ACTION_IDS: GitActionId[] = [
"merge-branch",
"pr",
"merge-from-base",
"push",
];
const SECONDARY_ACTION_IDS: GitActionId[] = ["merge-branch", "pr", "merge-from-base", "push"];
export function buildGitActions(input: BuildGitActionsInput): GitActions {
if (!input.isGit) {
@@ -134,7 +129,7 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
});
const primaryActionId = getPrimaryActionId(input);
const primary = primaryActionId ? allActions.get(primaryActionId) ?? null : null;
const primary = primaryActionId ? (allActions.get(primaryActionId) ?? null) : null;
const secondary = SECONDARY_ACTION_IDS.map((id) => allActions.get(id)!);
if (input.isPaseoOwnedWorktree) {
secondary.push(allActions.get("archive-worktree")!);
@@ -189,10 +184,7 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
label: "Create PR",
pendingLabel: "Creating PR...",
successLabel: "PR Created",
disabled:
input.runtime.pr.disabled ||
!input.githubFeaturesEnabled ||
input.aheadCount === 0,
disabled: input.runtime.pr.disabled || !input.githubFeaturesEnabled || input.aheadCount === 0,
status: input.runtime.pr.status,
description: getCreatePrDescription(input),
icon: input.runtime.pr.icon,
@@ -49,7 +49,9 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
) : (
<View style={styles.splitButtonContent}>
{gitActions.primary.icon}
<Text style={styles.splitButtonText}>{getActionDisplayLabel(gitActions.primary)}</Text>
<Text style={styles.splitButtonText}>
{getActionDisplayLabel(gitActions.primary)}
</Text>
</View>
)}
</Pressable>
File diff suppressed because it is too large Load Diff
@@ -11,21 +11,14 @@ interface BackHeaderProps {
onBack?: () => void;
}
export function BackHeader({
title,
rightContent,
onBack,
}: BackHeaderProps) {
export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) {
const { theme } = useUnistyles();
return (
<ScreenHeader
left={
<>
<Pressable
onPress={onBack ?? (() => router.back())}
style={styles.backButton}
>
<Pressable onPress={onBack ?? (() => router.back())} style={styles.backButton}>
<ArrowLeft size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
</Pressable>
{title && (
@@ -29,11 +29,7 @@ function MobileMenuIcon({ color }: { color: string }) {
<View style={[styles.mobileMenuLine, { backgroundColor: color }]} />
<View style={[styles.mobileMenuLine, { backgroundColor: color }]} />
<View
style={[
styles.mobileMenuLine,
styles.mobileMenuLineShort,
{ backgroundColor: color },
]}
style={[styles.mobileMenuLine, styles.mobileMenuLineShort, { backgroundColor: color }]}
/>
</View>
);
@@ -46,17 +42,15 @@ export function SidebarMenuToggle({
nativeID = "menu-button",
}: SidebarMenuToggleProps = {}) {
const { theme } = useUnistyles();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
const toggleShortcutKeys = getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."];
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
const menuIconColor = !isMobile && isOpen
? theme.colors.foreground
: theme.colors.foregroundMuted;
const menuIconColor =
!isMobile && isOpen ? theme.colors.foreground : theme.colors.foregroundMuted;
return (
<HeaderToggleButton
@@ -81,10 +75,7 @@ export function SidebarMenuToggle({
);
}
export function MenuHeader({
title,
rightContent,
}: MenuHeaderProps) {
export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
return (
<ScreenHeader
left={
@@ -22,12 +22,7 @@ interface ScreenHeaderProps {
* Shared frame for the home/back headers so we only maintain padding, border,
* and safe-area logic in one place.
*/
export function ScreenHeader({
left,
right,
leftStyle,
rightStyle,
}: ScreenHeaderProps) {
export function ScreenHeader({ left, right, leftStyle, rightStyle }: ScreenHeaderProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
@@ -37,9 +32,7 @@ export function ScreenHeader({
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
const baseHorizontalPadding = theme.spacing[2];
const collapsedSidebarTrafficLightInset =
!isMobile && !desktopAgentListOpen && getIsDesktopMac()
? trafficLightPadding.left
: 0;
!isMobile && !desktopAgentListOpen && getIsDesktopMac() ? trafficLightPadding.left : 0;
const dragHandlers = useDesktopDragHandlers();
@@ -25,10 +25,34 @@ export function SourceControlPanelIcon({
strokeLinejoin="round"
/>
{/* Plus */}
<Line x1={9} y1={9.5} x2={15} y2={9.5} stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" />
<Line x1={12} y1={6.5} x2={12} y2={12.5} stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" />
<Line
x1={9}
y1={9.5}
x2={15}
y2={9.5}
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
/>
<Line
x1={12}
y1={6.5}
x2={12}
y2={12.5}
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
/>
{/* Minus */}
<Line x1={9} y1={16} x2={15} y2={16} stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" />
<Line
x1={9}
y1={16}
x2={15}
y2={16}
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
/>
</Svg>
);
}
@@ -16,7 +16,7 @@ export function KeyboardShortcutsDialog() {
const isDesktopApp = getIsDesktop();
const sections = useMemo(
() => buildKeyboardShortcutHelpSections({ isMac, isDesktop: isDesktopApp }),
[isDesktopApp, isMac]
[isDesktopApp, isMac],
);
return (
+233 -221
View File
@@ -9,103 +9,108 @@ import {
type Dispatch,
type RefObject,
type SetStateAction,
} from 'react'
import { View, Pressable, Text, Platform } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
} from "react";
import { View, Pressable, Text, Platform } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, {
useAnimatedStyle,
interpolate,
Extrapolation,
runOnJS,
useSharedValue,
} from 'react-native-reanimated'
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
import { MessagesSquare, Plus, Settings } from 'lucide-react-native'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Shortcut } from '@/components/ui/shortcut'
import { router, usePathname } from 'expo-router'
import { usePanelStore } from '@/stores/panel-store'
import { SidebarWorkspaceList } from './sidebar-workspace-list'
import { SidebarAgentListSkeleton } from './sidebar-agent-list-skeleton'
import { useSidebarShortcutModel } from '@/hooks/use-sidebar-shortcut-model'
import { useSidebarWorkspacesList, type SidebarProjectEntry } from '@/hooks/use-sidebar-workspaces-list'
import { useSidebarAnimation } from '@/contexts/sidebar-animation-context'
import { useDesktopDragHandlers, useTrafficLightPadding } from '@/utils/desktop-window'
import { Combobox } from '@/components/ui/combobox'
import { getHostRuntimeStore, useHosts } from '@/runtime/host-runtime'
import { formatConnectionStatus } from '@/utils/daemons'
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from '@/constants/layout'
} from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { MessagesSquare, Plus, Settings } from "lucide-react-native";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
import { router, usePathname } from "expo-router";
import { usePanelStore } from "@/stores/panel-store";
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
import {
useSidebarWorkspacesList,
type SidebarProjectEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
import { Combobox } from "@/components/ui/combobox";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import { formatConnectionStatus } from "@/utils/daemons";
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout";
import {
buildHostSessionsRoute,
buildHostSettingsRoute,
mapPathnameToServer,
parseServerIdFromPathname,
} from '@/utils/host-routes'
import { useOpenProjectPicker } from '@/hooks/use-open-project-picker'
} from "@/utils/host-routes";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
const DESKTOP_SIDEBAR_WIDTH = 320
type SidebarShortcutModel = ReturnType<typeof useSidebarShortcutModel>
type SidebarTheme = ReturnType<typeof useUnistyles>['theme']
const DESKTOP_SIDEBAR_WIDTH = 320;
type SidebarShortcutModel = ReturnType<typeof useSidebarShortcutModel>;
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
interface LeftSidebarProps {
selectedAgentId?: string
selectedAgentId?: string;
}
interface HostOption {
id: string
label: string
description: string
id: string;
label: string;
description: string;
}
interface SidebarSharedProps {
theme: SidebarTheme
activeServerId: string | null
activeHostLabel: string
activeHostStatusColor: string
hostOptions: HostOption[]
hostTriggerRef: RefObject<View | null>
isHostPickerOpen: boolean
setIsHostPickerOpen: Dispatch<SetStateAction<boolean>>
projects: SidebarProjectEntry[]
isInitialLoad: boolean
isRevalidating: boolean
isManualRefresh: boolean
collapsedProjectKeys: SidebarShortcutModel['collapsedProjectKeys']
shortcutIndexByWorkspaceKey: SidebarShortcutModel['shortcutIndexByWorkspaceKey']
toggleProjectCollapsed: SidebarShortcutModel['toggleProjectCollapsed']
setProjectCollapsed: SidebarShortcutModel['setProjectCollapsed']
handleRefresh: () => void
handleHostSelect: (nextServerId: string) => void
handleOpenProject: () => void
handleSettings: () => void
theme: SidebarTheme;
activeServerId: string | null;
activeHostLabel: string;
activeHostStatusColor: string;
hostOptions: HostOption[];
hostTriggerRef: RefObject<View | null>;
isHostPickerOpen: boolean;
setIsHostPickerOpen: Dispatch<SetStateAction<boolean>>;
projects: SidebarProjectEntry[];
isInitialLoad: boolean;
isRevalidating: boolean;
isManualRefresh: boolean;
collapsedProjectKeys: SidebarShortcutModel["collapsedProjectKeys"];
shortcutIndexByWorkspaceKey: SidebarShortcutModel["shortcutIndexByWorkspaceKey"];
toggleProjectCollapsed: SidebarShortcutModel["toggleProjectCollapsed"];
setProjectCollapsed: SidebarShortcutModel["setProjectCollapsed"];
handleRefresh: () => void;
handleHostSelect: (nextServerId: string) => void;
handleOpenProject: () => void;
handleSettings: () => void;
}
interface MobileSidebarProps extends SidebarSharedProps {
insetsTop: number
insetsBottom: number
isOpen: boolean
closeToAgent: () => void
handleViewMoreNavigate: () => void
insetsTop: number;
insetsBottom: number;
isOpen: boolean;
closeToAgent: () => void;
handleViewMoreNavigate: () => void;
}
interface DesktopSidebarProps extends SidebarSharedProps {
isOpen: boolean
handleViewMore: () => void
isOpen: boolean;
handleViewMore: () => void;
}
export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarProps) {
void _selectedAgentId
export const LeftSidebar = memo(function LeftSidebar({
selectedAgentId: _selectedAgentId,
}: LeftSidebarProps) {
void _selectedAgentId;
const { theme } = useUnistyles()
const insets = useSafeAreaInsets()
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
const mobileView = usePanelStore((state) => state.mobileView)
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen)
const closeToAgent = usePanelStore((state) => state.closeToAgent)
const pathname = usePathname()
const daemons = useHosts()
const runtime = getHostRuntimeStore()
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const closeToAgent = usePanelStore((state) => state.closeToAgent);
const pathname = usePathname();
const daemons = useHosts();
const runtime = getHostRuntimeStore();
const runtimeConnectionStatusSignature = useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() =>
@@ -113,128 +118,132 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte
.map(
(daemon) =>
`${daemon.serverId}:${
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? 'connecting'
}`
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting"
}`,
)
.join('|'),
.join("|"),
() =>
daemons
.map(
(daemon) =>
`${daemon.serverId}:${
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? 'connecting'
}`
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting"
}`,
)
.join('|')
)
const activeServerIdFromPath = useMemo(() => parseServerIdFromPathname(pathname), [pathname])
.join("|"),
);
const activeServerIdFromPath = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
const activeDaemon = useMemo(() => {
if (daemons.length === 0) {
return null
return null;
}
if (activeServerIdFromPath) {
const routeMatch = daemons.find((entry) => entry.serverId === activeServerIdFromPath)
const routeMatch = daemons.find((entry) => entry.serverId === activeServerIdFromPath);
if (routeMatch) {
return routeMatch
return routeMatch;
}
}
return daemons[0] ?? null
}, [activeServerIdFromPath, daemons])
const activeServerId = activeDaemon?.serverId ?? null
return daemons[0] ?? null;
}, [activeServerIdFromPath, daemons]);
const activeServerId = activeDaemon?.serverId ?? null;
const activeHostLabel = useMemo(() => {
if (!activeDaemon) return 'No host'
const trimmed = activeDaemon.label?.trim()
return trimmed && trimmed.length > 0 ? trimmed : activeDaemon.serverId
}, [activeDaemon])
if (!activeDaemon) return "No host";
const trimmed = activeDaemon.label?.trim();
return trimmed && trimmed.length > 0 ? trimmed : activeDaemon.serverId;
}, [activeDaemon]);
const activeHostStatus = activeServerId
? (runtime.getSnapshot(activeServerId)?.connectionStatus ?? 'connecting')
: 'idle'
? (runtime.getSnapshot(activeServerId)?.connectionStatus ?? "connecting")
: "idle";
const activeHostStatusColor =
activeHostStatus === 'online'
activeHostStatus === "online"
? theme.colors.palette.green[400]
: activeHostStatus === 'connecting'
: activeHostStatus === "connecting"
? theme.colors.palette.amber[500]
: theme.colors.palette.red[500]
: theme.colors.palette.red[500];
const hostOptions = useMemo(
() =>
daemons.map((daemon) => ({
id: daemon.serverId,
label: daemon.label?.trim() || daemon.serverId,
description: formatConnectionStatus(
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? 'connecting'
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting",
),
})),
[daemons, runtime, runtimeConnectionStatusSignature]
)
const hostTriggerRef = useRef<View | null>(null)
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false)
[daemons, runtime, runtimeConnectionStatusSignature],
);
const hostTriggerRef = useRef<View | null>(null);
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
const isOpen = isMobile ? mobileView === 'agent-list' : desktopAgentListOpen
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({
serverId: activeServerId,
enabled: isOpen,
})
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed, setProjectCollapsed } =
useSidebarShortcutModel(projects)
});
const {
collapsedProjectKeys,
shortcutIndexByWorkspaceKey,
toggleProjectCollapsed,
setProjectCollapsed,
} = useSidebarShortcutModel(projects);
const [isManualRefresh, setIsManualRefresh] = useState(false)
const [isManualRefresh, setIsManualRefresh] = useState(false);
const handleRefresh = useCallback(() => {
setIsManualRefresh(true)
refreshAll()
}, [refreshAll])
setIsManualRefresh(true);
refreshAll();
}, [refreshAll]);
useEffect(() => {
if (!isRevalidating && isManualRefresh) {
setIsManualRefresh(false)
setIsManualRefresh(false);
}
}, [isRevalidating, isManualRefresh])
}, [isRevalidating, isManualRefresh]);
const openProjectPicker = useOpenProjectPicker(activeServerId)
const openProjectPicker = useOpenProjectPicker(activeServerId);
const handleOpenProjectMobile = useCallback(() => {
closeToAgent()
void openProjectPicker()
}, [closeToAgent, openProjectPicker])
closeToAgent();
void openProjectPicker();
}, [closeToAgent, openProjectPicker]);
const handleOpenProjectDesktop = useCallback(() => {
void openProjectPicker()
}, [openProjectPicker])
void openProjectPicker();
}, [openProjectPicker]);
const handleSettingsMobile = useCallback(() => {
if (!activeServerId) {
return
return;
}
closeToAgent()
router.push(buildHostSettingsRoute(activeServerId) as any)
}, [activeServerId, closeToAgent])
closeToAgent();
router.push(buildHostSettingsRoute(activeServerId) as any);
}, [activeServerId, closeToAgent]);
const handleSettingsDesktop = useCallback(() => {
if (!activeServerId) {
return
return;
}
router.push(buildHostSettingsRoute(activeServerId) as any)
}, [activeServerId])
router.push(buildHostSettingsRoute(activeServerId) as any);
}, [activeServerId]);
const handleViewMoreNavigate = useCallback(() => {
if (!activeServerId) {
return
return;
}
router.push(buildHostSessionsRoute(activeServerId) as any)
}, [activeServerId])
router.push(buildHostSessionsRoute(activeServerId) as any);
}, [activeServerId]);
const handleHostSelect = useCallback(
(nextServerId: string) => {
if (!nextServerId) {
return
return;
}
const nextPath = mapPathnameToServer(pathname, nextServerId)
setIsHostPickerOpen(false)
router.push(nextPath as any)
const nextPath = mapPathnameToServer(pathname, nextServerId);
setIsHostPickerOpen(false);
router.push(nextPath as any);
},
[pathname]
)
[pathname],
);
const sharedProps = {
theme,
@@ -255,7 +264,7 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte
setProjectCollapsed,
handleRefresh,
handleHostSelect,
}
};
if (isMobile) {
return (
@@ -269,7 +278,7 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte
handleSettings={handleSettingsMobile}
handleViewMoreNavigate={handleViewMoreNavigate}
/>
)
);
}
return (
@@ -280,13 +289,13 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte
handleSettings={handleSettingsDesktop}
handleViewMore={handleViewMoreNavigate}
/>
)
})
);
});
function SessionsButton({ onPress }: { onPress: () => void }) {
const { theme } = useUnistyles()
const pathname = usePathname()
const isActive = pathname.includes('/sessions')
const { theme } = useUnistyles();
const pathname = usePathname();
const isActive = pathname.includes("/sessions");
return (
<Pressable
@@ -308,14 +317,17 @@ function SessionsButton({ onPress }: { onPress: () => void }) {
color={hovered || isActive ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text
style={[styles.newAgentButtonText, (hovered || isActive) && styles.newAgentButtonTextHovered]}
style={[
styles.newAgentButtonText,
(hovered || isActive) && styles.newAgentButtonTextHovered,
]}
>
Sessions
</Text>
</>
)}
</Pressable>
)
);
}
function MobileSidebar({
@@ -353,22 +365,22 @@ function MobileSidebar({
animateToClose,
isGesturing,
closeGestureRef,
} = useSidebarAnimation()
const closeTouchStartX = useSharedValue(0)
const closeTouchStartY = useSharedValue(0)
} = useSidebarAnimation();
const closeTouchStartX = useSharedValue(0);
const closeTouchStartY = useSharedValue(0);
const handleClose = useCallback(() => {
closeToAgent()
}, [closeToAgent])
closeToAgent();
}, [closeToAgent]);
const handleViewMore = useCallback(() => {
if (!activeServerId) {
return
return;
}
translateX.value = -windowWidth
backdropOpacity.value = 0
closeToAgent()
handleViewMoreNavigate()
translateX.value = -windowWidth;
backdropOpacity.value = 0;
closeToAgent();
handleViewMoreNavigate();
}, [
activeServerId,
backdropOpacity,
@@ -376,7 +388,7 @@ function MobileSidebar({
handleViewMoreNavigate,
translateX,
windowWidth,
])
]);
const closeGesture = useMemo(
() =>
@@ -385,62 +397,62 @@ function MobileSidebar({
.enabled(isOpen)
.manualActivation(true)
.onTouchesDown((event) => {
const touch = event.changedTouches[0]
const touch = event.changedTouches[0];
if (!touch) {
return
return;
}
closeTouchStartX.value = touch.absoluteX
closeTouchStartY.value = touch.absoluteY
closeTouchStartX.value = touch.absoluteX;
closeTouchStartY.value = touch.absoluteY;
})
.onTouchesMove((event, stateManager) => {
const touch = event.changedTouches[0]
const touch = event.changedTouches[0];
if (!touch || event.numberOfTouches !== 1) {
stateManager.fail()
return
stateManager.fail();
return;
}
const deltaX = touch.absoluteX - closeTouchStartX.value
const deltaY = touch.absoluteY - closeTouchStartY.value
const absDeltaX = Math.abs(deltaX)
const absDeltaY = Math.abs(deltaY)
const deltaX = touch.absoluteX - closeTouchStartX.value;
const deltaY = touch.absoluteY - closeTouchStartY.value;
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
if (deltaX >= 10) {
stateManager.fail()
return
stateManager.fail();
return;
}
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
stateManager.fail()
return
stateManager.fail();
return;
}
if (deltaX <= -15 && absDeltaX > absDeltaY) {
stateManager.activate()
stateManager.activate();
}
})
.onStart(() => {
isGesturing.value = true
isGesturing.value = true;
})
.onUpdate((event) => {
const newTranslateX = Math.min(0, Math.max(-windowWidth, event.translationX))
translateX.value = newTranslateX
const newTranslateX = Math.min(0, Math.max(-windowWidth, event.translationX));
translateX.value = newTranslateX;
backdropOpacity.value = interpolate(
newTranslateX,
[-windowWidth, 0],
[0, 1],
Extrapolation.CLAMP
)
Extrapolation.CLAMP,
);
})
.onEnd((event) => {
isGesturing.value = false
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500
isGesturing.value = false;
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500;
if (shouldClose) {
animateToClose()
runOnJS(handleClose)()
animateToClose();
runOnJS(handleClose)();
} else {
animateToOpen()
animateToOpen();
}
})
.onFinalize(() => {
isGesturing.value = false
isGesturing.value = false;
}),
[
isOpen,
@@ -454,29 +466,29 @@ function MobileSidebar({
animateToClose,
animateToOpen,
handleClose,
]
)
],
);
const mobileSidebarInsetStyle = useMemo(
() => ({ width: windowWidth, paddingTop: insetsTop, paddingBottom: insetsBottom }),
[windowWidth, insetsTop, insetsBottom]
)
[windowWidth, insetsTop, insetsBottom],
);
const hostStatusDotStyle = useMemo(
() => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }],
[activeHostStatusColor]
)
[activeHostStatusColor],
);
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
}))
}));
const backdropAnimatedStyle = useAnimatedStyle(() => ({
opacity: backdropOpacity.value,
pointerEvents: backdropOpacity.value > 0.01 ? 'auto' : 'none',
}))
pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none",
}));
const overlayPointerEvents = Platform.OS === 'web' ? (isOpen ? 'auto' : 'none') : 'box-none'
const overlayPointerEvents = Platform.OS === "web" ? (isOpen ? "auto" : "none") : "box-none";
return (
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
@@ -554,7 +566,7 @@ function MobileSidebar({
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Add project</Text>
<Shortcut keys={['⌘', '⇧', 'O']} />
<Shortcut keys={["⌘", "⇧", "O"]} />
</View>
</TooltipContent>
</Tooltip>
@@ -578,7 +590,7 @@ function MobileSidebar({
</View>
<Combobox
options={hostOptions}
value={activeServerId ?? ''}
value={activeServerId ?? ""}
onSelect={handleHostSelect}
searchable={false}
title="Switch host"
@@ -592,7 +604,7 @@ function MobileSidebar({
</Animated.View>
</GestureDetector>
</View>
)
);
}
function DesktopSidebar({
@@ -619,15 +631,15 @@ function DesktopSidebar({
isOpen,
handleViewMore,
}: DesktopSidebarProps) {
const dragHandlers = useDesktopDragHandlers()
const trafficLightPadding = useTrafficLightPadding()
const dragHandlers = useDesktopDragHandlers();
const trafficLightPadding = useTrafficLightPadding();
const hostStatusDotStyle = useMemo(
() => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }],
[activeHostStatusColor]
)
[activeHostStatusColor],
);
if (!isOpen) {
return null
return null;
}
return (
@@ -697,7 +709,7 @@ function DesktopSidebar({
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Add project</Text>
<Shortcut keys={['⌘', '⇧', 'O']} />
<Shortcut keys={["⌘", "⇧", "O"]} />
</View>
</TooltipContent>
</Tooltip>
@@ -721,7 +733,7 @@ function DesktopSidebar({
</View>
<Combobox
options={hostOptions}
value={activeServerId ?? ''}
value={activeServerId ?? ""}
onSelect={handleHostSelect}
searchable={false}
title="Switch host"
@@ -732,29 +744,29 @@ function DesktopSidebar({
/>
</View>
</View>
)
);
}
const styles = StyleSheet.create((theme) => ({
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
backdropPressable: {
flex: 1,
},
mobileSidebar: {
position: 'absolute',
position: "absolute",
top: 0,
left: 0,
bottom: 0,
backgroundColor: theme.colors.surfaceSidebar,
overflow: 'hidden',
overflow: "hidden",
},
sidebarContent: {
flex: 1,
minHeight: 0,
overflow: 'hidden',
overflow: "hidden",
},
desktopSidebar: {
borderRightWidth: 1,
@@ -767,21 +779,21 @@ const styles = StyleSheet.create((theme) => ({
md: HEADER_INNER_HEIGHT,
},
paddingHorizontal: theme.spacing[2],
justifyContent: 'center',
justifyContent: "center",
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
userSelect: 'none',
userSelect: "none",
},
sidebarHeaderRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
},
newAgentButton: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
@@ -802,9 +814,9 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface1,
},
hostTrigger: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-start',
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-start",
gap: theme.spacing[2],
minWidth: 0,
paddingVertical: theme.spacing[1],
@@ -826,9 +838,9 @@ const styles = StyleSheet.create((theme) => ({
minWidth: 0,
},
sidebarFooter: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderTopWidth: 1,
@@ -841,16 +853,16 @@ const styles = StyleSheet.create((theme) => ({
marginRight: theme.spacing[2],
},
footerIconRow: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
flexShrink: 0,
},
footerIconButton: {
width: 28,
height: 28,
alignItems: 'center',
justifyContent: 'center',
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
},
@@ -876,19 +888,19 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface0,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
alignItems: 'center',
alignItems: "center",
},
hostPickerCancelText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
tooltipRow: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
tooltipText: {
fontSize: theme.fontSize.sm,
color: theme.colors.popoverForeground,
},
}))
}));
File diff suppressed because it is too large Load Diff
+162 -329
View File
@@ -40,11 +40,7 @@ import {
TriangleAlertIcon,
Scissors,
} from "lucide-react-native";
import {
StyleSheet,
useUnistyles,
UnistylesRuntime,
} from "react-native-unistyles";
import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles";
import Animated, {
Easing,
cancelAnimation,
@@ -53,23 +49,14 @@ import Animated, {
withRepeat,
withTiming,
} from "react-native-reanimated";
import Svg, {
Defs,
LinearGradient as SvgLinearGradient,
Rect,
Stop,
} from "react-native-svg";
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
import { theme } from "@/styles/theme";
import {
createMarkdownStyles,
} from "@/styles/markdown-styles";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { Colors, Fonts } from "@/constants/theme";
import * as Clipboard from "expo-clipboard";
import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream";
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
import {
buildToolCallDisplayModel,
} from "@/utils/tool-call-display";
import { buildToolCallDisplayModel } from "@/utils/tool-call-display";
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
import {
hasMeaningfulToolCallDetail,
@@ -159,10 +146,7 @@ function ensureWebToolCallShimmerKeyframes() {
webToolCallShimmerRegistered = true;
}
function getWheelEventElementTarget(
event: WheelEvent,
fallback: HTMLElement
): HTMLElement {
function getWheelEventElementTarget(event: WheelEvent, fallback: HTMLElement): HTMLElement {
const { target } = event;
if (target instanceof HTMLElement) {
return target;
@@ -176,7 +160,7 @@ function getWheelEventElementTarget(
function canElementScrollInDirection(
element: HTMLElement,
axis: ScrollAxis,
delta: number
delta: number,
): boolean {
if (delta === 0) {
return false;
@@ -192,7 +176,9 @@ function canElementScrollInDirection(
const scrollPosition = axis === "x" ? element.scrollLeft : element.scrollTop;
const scrollSize =
axis === "x" ? element.scrollWidth - element.clientWidth : element.scrollHeight - element.clientHeight;
axis === "x"
? element.scrollWidth - element.clientWidth
: element.scrollHeight - element.clientHeight;
if (scrollSize <= SCROLL_EDGE_EPSILON) {
return false;
}
@@ -207,7 +193,7 @@ function canScrollInsideDetailFromTarget(
detailRoot: HTMLElement,
startElement: HTMLElement,
axis: ScrollAxis,
delta: number
delta: number,
): boolean {
if (delta === 0) {
return false;
@@ -226,14 +212,10 @@ function canScrollInsideDetailFromTarget(
return false;
}
function shouldStopDetailWheelPropagation(
detailRoot: HTMLElement,
event: WheelEvent
): boolean {
function shouldStopDetailWheelPropagation(detailRoot: HTMLElement, event: WheelEvent): boolean {
const startElement = getWheelEventElementTarget(event, detailRoot);
const verticalDelta = event.deltaY;
const horizontalDelta =
event.deltaX !== 0 ? event.deltaX : (event.shiftKey ? event.deltaY : 0);
const horizontalDelta = event.deltaX !== 0 ? event.deltaX : event.shiftKey ? event.deltaY : 0;
const hasVerticalIntent = Math.abs(verticalDelta) > SCROLL_EDGE_EPSILON;
const hasHorizontalIntent = Math.abs(horizontalDelta) > SCROLL_EDGE_EPSILON;
@@ -245,12 +227,7 @@ function shouldStopDetailWheelPropagation(
? canScrollInsideDetailFromTarget(detailRoot, startElement, "y", verticalDelta)
: false;
const canScrollHorizontally = hasHorizontalIntent
? canScrollInsideDetailFromTarget(
detailRoot,
startElement,
"x",
horizontalDelta
)
? canScrollInsideDetailFromTarget(detailRoot, startElement, "x", horizontalDelta)
: false;
if (hasVerticalIntent && hasHorizontalIntent) {
@@ -336,11 +313,7 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
},
}));
function UserMessageAttachmentThumbnail({
image,
}: {
image: UserMessageImageAttachment;
}) {
function UserMessageAttachmentThumbnail({ image }: { image: UserMessageImageAttachment }) {
const uri = useAttachmentPreviewUrl(image);
if (!uri) {
return <View style={userMessageStylesheet.imageThumbnailPlaceholder} />;
@@ -358,12 +331,10 @@ export const UserMessage = memo(function UserMessage({
}: UserMessageProps) {
const [messageHovered, setMessageHovered] = useState(false);
const [copyButtonHovered, setCopyButtonHovered] = useState(false);
const resolvedDisableOuterSpacing =
useDisableOuterSpacing(disableOuterSpacing);
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const hasText = message.trim().length > 0;
const hasImages = images.length > 0;
const showCopyButton =
hasText && (Platform.OS !== "web" || messageHovered || copyButtonHovered);
const showCopyButton = hasText && (Platform.OS !== "web" || messageHovered || copyButtonHovered);
return (
<View
@@ -372,20 +343,14 @@ export const UserMessage = memo(function UserMessage({
!resolvedDisableOuterSpacing && [
isFirstInGroup && { marginTop: theme.spacing[4] },
isLastInGroup && { marginBottom: theme.spacing[4] },
!isFirstInGroup || !isLastInGroup
? { marginBottom: theme.spacing[1] }
: undefined,
!isFirstInGroup || !isLastInGroup ? { marginBottom: theme.spacing[1] } : undefined,
],
]}
>
<Pressable
style={userMessageStylesheet.content}
onHoverIn={
Platform.OS === "web" ? () => setMessageHovered(true) : undefined
}
onHoverOut={
Platform.OS === "web" ? () => setMessageHovered(false) : undefined
}
onHoverIn={Platform.OS === "web" ? () => setMessageHovered(true) : undefined}
onHoverOut={Platform.OS === "web" ? () => setMessageHovered(false) : undefined}
>
<View style={userMessageStylesheet.bubble}>
{hasImages ? (
@@ -396,10 +361,7 @@ export const UserMessage = memo(function UserMessage({
]}
>
{images.map((image, index) => (
<View
key={`${image.id}-${index}`}
style={userMessageStylesheet.imagePill}
>
<View key={`${image.id}-${index}`} style={userMessageStylesheet.imagePill}>
<UserMessageAttachmentThumbnail image={image} />
</View>
))}
@@ -475,11 +437,7 @@ function MarkdownLink({
const [hovered, setHovered] = useState(false);
if (Platform.OS !== "web") {
return (
<Text
accessibilityRole="link"
onPress={() => onPress(href)}
style={style}
>
<Text accessibilityRole="link" onPress={() => onPress(href)} style={style}>
{children}
</Text>
);
@@ -492,25 +450,25 @@ function MarkdownLink({
onHoverIn={() => setHovered(true)}
onHoverOut={() => setHovered(false)}
>
<Text style={[style, hovered && { textDecorationLine: "underline" }]}>
{children}
</Text>
<Text style={[style, hovered && { textDecorationLine: "underline" }]}>{children}</Text>
</Pressable>
);
}
function getInlineCodeAutoLinkUrl(
markdownParser: ReturnType<typeof MarkdownIt>,
content: string
content: string,
): string | null {
const trimmed = content.trim();
if (!trimmed) {
return null;
}
const matches = markdownParser.linkify.match(trimmed) as
| Array<{ index: number; lastIndex: number; url: string }>
| null;
const matches = markdownParser.linkify.match(trimmed) as Array<{
index: number;
lastIndex: number;
url: string;
}> | null;
if (!matches || matches.length !== 1) {
return null;
}
@@ -604,9 +562,7 @@ export const TurnCopyButton = memo(function TurnCopyButton({
style={[turnCopyButtonStylesheet.container, containerStyle]}
accessibilityRole="button"
accessibilityLabel={
copied
? (copiedAccessibilityLabel ?? "Copied")
: (accessibilityLabel ?? "Copy turn")
copied ? (copiedAccessibilityLabel ?? "Copied") : (accessibilityLabel ?? "Copy turn")
}
>
{({ hovered }) => {
@@ -767,41 +723,38 @@ export const AssistantMessage = memo(function AssistantMessage({
});
const { theme, rt } = useUnistyles();
const resolvedDisableOuterSpacing =
useDisableOuterSpacing(disableOuterSpacing);
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [rt.themeName]);
const markdownParser = useMemo(
() => {
const parser = MarkdownIt({ typographer: true, linkify: true });
const defaultValidateLink = parser.validateLink.bind(parser);
parser.validateLink = (url: string) => {
if (url.trim().toLowerCase().startsWith("file://")) {
return true;
}
const markdownParser = useMemo(() => {
const parser = MarkdownIt({ typographer: true, linkify: true });
const defaultValidateLink = parser.validateLink.bind(parser);
parser.validateLink = (url: string) => {
if (url.trim().toLowerCase().startsWith("file://")) {
return true;
}
return defaultValidateLink(url);
};
return parser;
},
[]
);
return defaultValidateLink(url);
};
return parser;
}, []);
const handleLinkPress = useCallback((url: string) => {
const fileTarget = onInlinePathPress
? parseAssistantFileLink(url, { workspaceRoot })
: null;
if (fileTarget) {
onInlinePathPress?.(fileTarget);
const handleLinkPress = useCallback(
(url: string) => {
const fileTarget = onInlinePathPress ? parseAssistantFileLink(url, { workspaceRoot }) : null;
if (fileTarget) {
onInlinePathPress?.(fileTarget);
return false;
}
void openExternalUrl(url);
// react-native-markdown-display opens the link itself when this returns true.
// We already handled it above, so return false to avoid duplicate opens.
return false;
}
void openExternalUrl(url);
// react-native-markdown-display opens the link itself when this returns true.
// We already handled it above, so return false to avoid duplicate opens.
return false;
}, [onInlinePathPress, workspaceRoot]);
},
[onInlinePathPress, workspaceRoot],
);
const markdownRules = useMemo(() => {
return {
@@ -810,7 +763,7 @@ export const AssistantMessage = memo(function AssistantMessage({
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {}
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.text]}>
{node.content}
@@ -821,12 +774,9 @@ export const AssistantMessage = memo(function AssistantMessage({
children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {}
inheritedStyles: any = {},
) => (
<Text
key={node.key}
style={[inheritedStyles, styles.textgroup]}
>
<Text key={node.key} style={[inheritedStyles, styles.textgroup]}>
{children}
</Text>
),
@@ -835,12 +785,9 @@ export const AssistantMessage = memo(function AssistantMessage({
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {}
inheritedStyles: any = {},
) => (
<Text
key={node.key}
style={[inheritedStyles, styles.code_block]}
>
<Text key={node.key} style={[inheritedStyles, styles.code_block]}>
{node.content}
</Text>
),
@@ -849,7 +796,7 @@ export const AssistantMessage = memo(function AssistantMessage({
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {}
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.fence]}>
{node.content}
@@ -860,16 +807,14 @@ export const AssistantMessage = memo(function AssistantMessage({
_children: ReactNode[],
parent: any,
styles: any,
inheritedStyles: any = {}
inheritedStyles: any = {},
) => {
const content = node.content ?? "";
const isLinkedInlineCode =
nodeHasParentType(parent, "link") ||
(!Array.isArray(parent) &&
typeof parent?.attributes?.href === "string");
const parsed = onInlinePathPress && !isLinkedInlineCode
? parseInlinePathToken(content)
: null;
(!Array.isArray(parent) && typeof parent?.attributes?.href === "string");
const parsed =
onInlinePathPress && !isLinkedInlineCode ? parseInlinePathToken(content) : null;
if (parsed) {
return (
@@ -877,10 +822,7 @@ export const AssistantMessage = memo(function AssistantMessage({
key={node.key}
onPress={() => parsed && onInlinePathPress?.(parsed)}
selectable={false}
style={[
assistantMessageStylesheet.pathChip,
assistantMessageStylesheet.pathChipText,
]}
style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]}
>
{content}
</Text>
@@ -902,81 +844,42 @@ export const AssistantMessage = memo(function AssistantMessage({
}
return (
<Text
key={node.key}
style={[inheritedStyles, styles.code_inline]}
>
<Text key={node.key} style={[inheritedStyles, styles.code_inline]}>
{content}
</Text>
);
},
bullet_list: (
node: any,
children: ReactNode[],
_parent: any,
styles: any
) => (
bullet_list: (node: any, children: ReactNode[], _parent: any, styles: any) => (
<View key={node.key} style={styles.bullet_list}>
{children}
</View>
),
ordered_list: (
node: any,
children: ReactNode[],
_parent: any,
styles: any
) => (
ordered_list: (node: any, children: ReactNode[], _parent: any, styles: any) => (
<View key={node.key} style={styles.ordered_list}>
{children}
</View>
),
list_item: (
node: any,
children: ReactNode[],
parent: any,
styles: any
) => {
list_item: (node: any, children: ReactNode[], parent: any, styles: any) => {
const { isOrdered, marker } = getMarkdownListMarker(node, parent);
const iconStyle = isOrdered
? styles.ordered_list_icon
: styles.bullet_list_icon;
const contentStyle = isOrdered
? styles.ordered_list_content
: styles.bullet_list_content;
const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon;
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
return (
<View key={node.key} style={styles.list_item}>
<Text style={iconStyle}>{marker}</Text>
<View
style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}
>
{children}
</View>
<View style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>{children}</View>
</View>
);
},
paragraph: (
node: any,
children: ReactNode[],
parent: any,
styles: any,
) => {
paragraph: (node: any, children: ReactNode[], parent: any, styles: any) => {
const isLastChild = parent[0]?.children?.at(-1)?.key === node.key;
return (
<View
key={node.key}
style={[styles.paragraph, isLastChild && { marginBottom: 0 }]}
>
<View key={node.key} style={[styles.paragraph, isLastChild && { marginBottom: 0 }]}>
{children}
</View>
);
},
link: (
node: any,
children: ReactNode[],
_parent: any,
styles: any,
) => (
link: (node: any, children: ReactNode[], _parent: any, styles: any) => (
<MarkdownLink
key={node.key}
href={node.attributes?.href ?? ""}
@@ -985,8 +888,10 @@ export const AssistantMessage = memo(function AssistantMessage({
>
{Children.map(children, (child) =>
isValidElement(child)
? cloneElement(child, { style: [(child.props as any).style, { color: styles.link.color }] } as any)
: child
? cloneElement(child, {
style: [(child.props as any).style, { color: styles.link.color }],
} as any)
: child,
)}
</MarkdownLink>
),
@@ -998,8 +903,7 @@ export const AssistantMessage = memo(function AssistantMessage({
testID="assistant-message"
style={[
assistantMessageStylesheet.container,
!resolvedDisableOuterSpacing &&
assistantMessageStylesheet.containerSpacing,
!resolvedDisableOuterSpacing && assistantMessageStylesheet.containerSpacing,
]}
>
<Markdown
@@ -1109,8 +1013,7 @@ export const ActivityLog = memo(function ActivityLog({
onArtifactClick,
disableOuterSpacing,
}: ActivityLogProps) {
const resolvedDisableOuterSpacing =
useDisableOuterSpacing(disableOuterSpacing);
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const [isExpanded, setIsExpanded] = useState(false);
const typeConfig = {
@@ -1149,9 +1052,7 @@ export const ActivityLog = memo(function ActivityLog({
};
const displayMessage =
type === "artifact" && artifactType && title
? `${artifactType}: ${title}`
: message;
type === "artifact" && artifactType && title ? `${artifactType}: ${title}` : message;
const isInteractive = type === "artifact" || metadata;
@@ -1172,12 +1073,7 @@ export const ActivityLog = memo(function ActivityLog({
<IconComponent size={16} color={config.color} />
</View>
<View style={activityLogStylesheet.textContainer}>
<Text
style={[
activityLogStylesheet.messageText,
{ color: config.color },
]}
>
<Text style={[activityLogStylesheet.messageText, { color: config.color }]}>
{displayMessage}
</Text>
{metadata && (
@@ -1402,21 +1298,17 @@ const ExpandableBadge = memo(function ExpandableBadge({
testID,
}: ExpandableBadgeProps) {
const { theme } = useUnistyles();
const resolvedDisableOuterSpacing =
useDisableOuterSpacing(disableOuterSpacing);
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const [isHovered, setIsHovered] = useState(false);
const [isPressed, setIsPressed] = useState(false);
const isInteractive = Boolean(onToggle);
const hasDetailContent = Boolean(renderDetails);
const detailContent =
hasDetailContent && isExpanded ? renderDetails?.() : null;
const detailContent = hasDetailContent && isExpanded ? renderDetails?.() : null;
const detailWrapperRef = useRef<View | null>(null);
const wheelInvestigationComponentId = `ExpandableBadgeWheel:${
testID ?? label
}`;
const wheelInvestigationComponentId = `ExpandableBadgeWheel:${testID ?? label}`;
const nativeGradientIdRef = useRef(
`shimmer-gradient-${Math.random().toString(36).substring(2, 9)}`
`shimmer-gradient-${Math.random().toString(36).substring(2, 9)}`,
);
const [labelRowWidth, setLabelRowWidth] = useState(0);
const [labelRowHeight, setLabelRowHeight] = useState(0);
@@ -1426,77 +1318,64 @@ const ExpandableBadge = memo(function ExpandableBadge({
const [secondaryWidth, setSecondaryWidth] = useState(0);
const shimmerTranslateX = useSharedValue(0);
const totalShimmerChars =
label.trim().length + (secondaryLabel?.trim().length ?? 0);
const totalShimmerChars = label.trim().length + (secondaryLabel?.trim().length ?? 0);
const shortTextDurationAdjustment = totalShimmerChars <= 12 ? 0.25 : 0;
const shimmerDuration = Math.max(
1,
Math.min(2.3, 1.25 + totalShimmerChars * 0.008 - shortTextDurationAdjustment)
Math.min(2.3, 1.25 + totalShimmerChars * 0.008 - shortTextDurationAdjustment),
);
const nativeShimmerPeakWidth = Math.max(
32,
Math.min(120, labelRowWidth > 0 ? labelRowWidth * 0.28 : 0)
Math.min(120, labelRowWidth > 0 ? labelRowWidth * 0.28 : 0),
);
const isWebShimmer = isLoading && Platform.OS === "web";
const shouldMeasureWebShimmer = isWebShimmer;
const shouldMeasureNativeShimmer = isLoading && Platform.OS !== "web";
const isNativeShimmer =
shouldMeasureNativeShimmer &&
labelRowWidth > 0 &&
labelRowHeight > 0;
const isNativeShimmer = shouldMeasureNativeShimmer && labelRowWidth > 0 && labelRowHeight > 0;
const webShimmerSpanStartX = labelOffsetX;
const webShimmerSpanEndX = secondaryLabel
? secondaryOffsetX + secondaryWidth
: labelOffsetX + labelWidth;
const webShimmerSpanWidth = Math.max(
1,
webShimmerSpanEndX - webShimmerSpanStartX
);
const webShimmerPeakWidth = Math.max(
42,
Math.min(120, webShimmerSpanWidth * 0.22)
);
const webShimmerSpanWidth = Math.max(1, webShimmerSpanEndX - webShimmerSpanStartX);
const webShimmerPeakWidth = Math.max(42, Math.min(120, webShimmerSpanWidth * 0.22));
const webShimmerTrackStart = webShimmerSpanStartX - webShimmerPeakWidth;
const webShimmerTrackEnd = webShimmerSpanEndX;
const handleLabelRowLayout = useCallback((event: LayoutChangeEvent) => {
if (!shouldMeasureNativeShimmer) {
return;
}
const { width, height } = event.nativeEvent.layout;
setLabelRowWidth((previous) =>
Math.abs(previous - width) > 0.5 ? width : previous
);
setLabelRowHeight((previous) =>
Math.abs(previous - height) > 0.5 ? height : previous
);
}, [shouldMeasureNativeShimmer]);
const handleLabelRowLayout = useCallback(
(event: LayoutChangeEvent) => {
if (!shouldMeasureNativeShimmer) {
return;
}
const { width, height } = event.nativeEvent.layout;
setLabelRowWidth((previous) => (Math.abs(previous - width) > 0.5 ? width : previous));
setLabelRowHeight((previous) => (Math.abs(previous - height) > 0.5 ? height : previous));
},
[shouldMeasureNativeShimmer],
);
const handleLabelLayout = useCallback((event: LayoutChangeEvent) => {
if (!shouldMeasureWebShimmer) {
return;
}
const { x, width } = event.nativeEvent.layout;
setLabelOffsetX((previous) =>
Math.abs(previous - x) > 0.5 ? x : previous
);
setLabelWidth((previous) =>
Math.abs(previous - width) > 0.5 ? width : previous
);
}, [shouldMeasureWebShimmer]);
const handleLabelLayout = useCallback(
(event: LayoutChangeEvent) => {
if (!shouldMeasureWebShimmer) {
return;
}
const { x, width } = event.nativeEvent.layout;
setLabelOffsetX((previous) => (Math.abs(previous - x) > 0.5 ? x : previous));
setLabelWidth((previous) => (Math.abs(previous - width) > 0.5 ? width : previous));
},
[shouldMeasureWebShimmer],
);
const handleSecondaryLayout = useCallback((event: LayoutChangeEvent) => {
if (!shouldMeasureWebShimmer || !secondaryLabel) {
return;
}
const { x, width } = event.nativeEvent.layout;
setSecondaryOffsetX((previous) =>
Math.abs(previous - x) > 0.5 ? x : previous
);
setSecondaryWidth((previous) =>
Math.abs(previous - width) > 0.5 ? width : previous
);
}, [shouldMeasureWebShimmer, secondaryLabel]);
const handleSecondaryLayout = useCallback(
(event: LayoutChangeEvent) => {
if (!shouldMeasureWebShimmer || !secondaryLabel) {
return;
}
const { x, width } = event.nativeEvent.layout;
setSecondaryOffsetX((previous) => (Math.abs(previous - x) > 0.5 ? x : previous));
setSecondaryWidth((previous) => (Math.abs(previous - width) > 0.5 ? width : previous));
},
[shouldMeasureWebShimmer, secondaryLabel],
);
useEffect(() => {
if (!isWebShimmer) {
@@ -1520,18 +1399,12 @@ const ExpandableBadge = memo(function ExpandableBadge({
easing: Easing.linear,
}),
-1,
false
false,
);
return () => {
cancelAnimation(shimmerTranslateX);
};
}, [
isNativeShimmer,
labelRowWidth,
nativeShimmerPeakWidth,
shimmerDuration,
shimmerTranslateX,
]);
}, [isNativeShimmer, labelRowWidth, nativeShimmerPeakWidth, shimmerDuration, shimmerTranslateX]);
useEffect(() => {
if (Platform.OS !== "web" || !isExpanded || !hasDetailContent) {
@@ -1605,23 +1478,21 @@ const ExpandableBadge = memo(function ExpandableBadge({
: expandableBadgeStylesheet.containerSpacing),
style,
],
[isLastInSequence, resolvedDisableOuterSpacing, style]
[isLastInSequence, resolvedDisableOuterSpacing, style],
);
const pressableStyle = useMemo(
() => [
expandableBadgeStylesheet.pressable,
isPressed && isInteractive
? expandableBadgeStylesheet.pressablePressed
: null,
isPressed && isInteractive ? expandableBadgeStylesheet.pressablePressed : null,
isExpanded && expandableBadgeStylesheet.pressableExpanded,
],
[isExpanded, isInteractive, isPressed]
[isExpanded, isInteractive, isPressed],
);
const accessibilityState = useMemo(
() => (isInteractive ? { expanded: isExpanded } : undefined),
[isExpanded, isInteractive]
[isExpanded, isInteractive],
);
const isActive = isHovered || isExpanded;
@@ -1632,7 +1503,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
isActive && expandableBadgeStylesheet.labelActive,
isLoading && expandableBadgeStylesheet.labelLoading,
],
[isActive, isLoading]
[isActive, isLoading],
);
const secondaryLabelStyle = useMemo(
@@ -1640,7 +1511,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
expandableBadgeStylesheet.secondaryLabel,
isActive && expandableBadgeStylesheet.secondaryLabelActive,
],
[isActive]
[isActive],
);
const shimmerLabelTextStyle = useMemo(
@@ -1650,7 +1521,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
expandableBadgeStylesheet.shimmerText,
shimmerLabelStyle,
],
[isLoading, shimmerLabelStyle]
[isLoading, shimmerLabelStyle],
);
const shimmerSecondaryTextStyle = useMemo(
@@ -1659,7 +1530,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
expandableBadgeStylesheet.shimmerText,
shimmerSecondaryStyle,
],
[shimmerSecondaryStyle]
[shimmerSecondaryStyle],
);
const nativeShimmerTrackStyle = useMemo(
@@ -1667,7 +1538,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
expandableBadgeStylesheet.nativeShimmerTrack,
{ width: labelRowWidth, height: labelRowHeight },
],
[labelRowHeight, labelRowWidth]
[labelRowHeight, labelRowWidth],
);
const nativeShimmerMaskStyle = useMemo(
@@ -1675,20 +1546,17 @@ const ExpandableBadge = memo(function ExpandableBadge({
expandableBadgeStylesheet.shimmerMaskRow,
{ width: labelRowWidth, height: labelRowHeight },
],
[labelRowHeight, labelRowWidth]
[labelRowHeight, labelRowWidth],
);
const nativeLabelMaskStyle = useMemo(
() => [expandableBadgeStylesheet.label, { color: "#000000", opacity: 1 }],
[]
[],
);
const nativeSecondaryMaskStyle = useMemo(
() => [
expandableBadgeStylesheet.secondaryLabel,
{ color: "#000000", opacity: 1 },
],
[]
() => [expandableBadgeStylesheet.secondaryLabel, { color: "#000000", opacity: 1 }],
[],
);
const nativeShimmerPeakCombinedStyle = useMemo(
@@ -1697,7 +1565,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
nativeShimmerPeakStyle,
{ width: nativeShimmerPeakWidth, height: labelRowHeight },
],
[labelRowHeight, nativeShimmerPeakStyle, nativeShimmerPeakWidth]
[labelRowHeight, nativeShimmerPeakStyle, nativeShimmerPeakWidth],
);
const chevronStyle = useMemo(
@@ -1705,7 +1573,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
expandableBadgeStylesheet.chevron,
isExpanded && expandableBadgeStylesheet.chevronExpanded,
],
[isExpanded]
[isExpanded],
);
const IconComponent = icon;
@@ -1723,10 +1591,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
}
return (
<View
style={containerStyle}
testID={testID}
>
<View style={containerStyle} testID={testID}>
<Pressable
onPress={isInteractive ? onToggle : undefined}
onHoverIn={isInteractive ? () => setIsHovered(true) : undefined}
@@ -1770,21 +1635,12 @@ const ExpandableBadge = memo(function ExpandableBadge({
<View style={expandableBadgeStylesheet.spacer} />
)}
{isWebShimmer ? (
<View
style={expandableBadgeStylesheet.shimmerOverlay}
pointerEvents="none"
>
<Text
style={shimmerLabelTextStyle}
numberOfLines={1}
>
<View style={expandableBadgeStylesheet.shimmerOverlay} pointerEvents="none">
<Text style={shimmerLabelTextStyle} numberOfLines={1}>
{label}
</Text>
{secondaryLabel ? (
<Text
style={shimmerSecondaryTextStyle}
numberOfLines={1}
>
<Text style={shimmerSecondaryTextStyle} numberOfLines={1}>
{secondaryLabel}
</Text>
) : (
@@ -1793,25 +1649,16 @@ const ExpandableBadge = memo(function ExpandableBadge({
</View>
) : null}
{isNativeShimmer ? (
<View
style={expandableBadgeStylesheet.shimmerOverlay}
pointerEvents="none"
>
<View style={expandableBadgeStylesheet.shimmerOverlay} pointerEvents="none">
<MaskedView
style={nativeShimmerTrackStyle}
maskElement={
<View style={nativeShimmerMaskStyle}>
<Text
style={nativeLabelMaskStyle}
numberOfLines={1}
>
<Text style={nativeLabelMaskStyle} numberOfLines={1}>
{label}
</Text>
{secondaryLabel ? (
<Text
style={nativeSecondaryMaskStyle}
numberOfLines={1}
>
<Text style={nativeSecondaryMaskStyle} numberOfLines={1}>
{secondaryLabel}
</Text>
) : (
@@ -1820,9 +1667,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
</View>
}
>
<View
style={nativeShimmerTrackStyle}
>
<View style={nativeShimmerTrackStyle}>
<Animated.View style={nativeShimmerPeakCombinedStyle}>
<Svg width="100%" height="100%" preserveAspectRatio="none">
<Defs>
@@ -1853,11 +1698,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
) : null}
</View>
{isInteractive && isHovered ? (
<ChevronRight
size={14}
color={theme.colors.foreground}
style={chevronStyle}
/>
<ChevronRight size={14} color={theme.colors.foreground} style={chevronStyle} />
) : null}
</View>
</Pressable>
@@ -1875,10 +1716,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
);
}, areExpandableBadgePropsEqual);
function areExpandableBadgePropsEqual(
previous: ExpandableBadgeProps,
next: ExpandableBadgeProps
) {
function areExpandableBadgePropsEqual(previous: ExpandableBadgeProps, next: ExpandableBadgeProps) {
if (previous.label !== next.label) return false;
if (previous.secondaryLabel !== next.secondaryLabel) return false;
if (previous.icon !== next.icon) return false;
@@ -1932,9 +1770,7 @@ export const ToolCall = memo(function ToolCall({
const [isExpanded, setIsExpanded] = useState(false);
// Check if we're on mobile (use bottom sheet) or desktop (inline expand)
const isMobile =
UnistylesRuntime.breakpoint === "xs" ||
UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const effectiveDetail = useMemo<ToolCallDetail | undefined>(() => {
if (detail) {
@@ -1950,12 +1786,11 @@ export const ToolCall = memo(function ToolCall({
return undefined;
}, [detail, args, result]);
const displayDetail =
effectiveDetail ?? {
type: "unknown",
input: null,
output: null,
};
const displayDetail = effectiveDetail ?? {
type: "unknown",
input: null,
output: null,
};
const displayModel = useMemo(
() =>
@@ -1967,7 +1802,7 @@ export const ToolCall = memo(function ToolCall({
metadata,
cwd,
}),
[toolName, status, error, displayDetail, metadata, cwd]
[toolName, status, error, displayDetail, metadata, cwd],
);
const displayName = displayModel.displayName;
const summary = displayModel.summary;
@@ -1981,9 +1816,7 @@ export const ToolCall = memo(function ToolCall({
const secondaryLabel = summary;
// Check if there's any content to display
const hasDetails =
Boolean(error) ||
hasMeaningfulToolCallDetail(effectiveDetail);
const hasDetails = Boolean(error) || hasMeaningfulToolCallDetail(effectiveDetail);
const canOpenDetails = hasDetails || isLoadingDetails;
const handleToggle = useCallback(() => {
@@ -18,12 +18,7 @@ export function ModeSelectorModal({
const { theme } = useUnistyles();
return (
<Modal
visible={visible}
animationType="fade"
transparent={true}
onRequestClose={onClose}
>
<Modal visible={visible} animationType="fade" transparent={true} onRequestClose={onClose}>
<Pressable style={styles.modalOverlay} onPress={onClose}>
<View style={styles.modeSelectorContent}>
{agent?.availableModes?.map((mode) => {
@@ -35,26 +30,13 @@ export function ModeSelectorModal({
onModeChange(mode.id);
onClose();
}}
style={[
styles.modeItem,
isActive && styles.modeItemActive,
]}
style={[styles.modeItem, isActive && styles.modeItemActive]}
>
<Text
style={[
styles.modeName,
isActive && styles.modeNameActive,
]}
>
<Text style={[styles.modeName, isActive && styles.modeNameActive]}>
{mode.label}
</Text>
{mode.description && (
<Text
style={[
styles.modeDescription,
isActive && styles.modeDescriptionActive,
]}
>
<Text style={[styles.modeDescription, isActive && styles.modeDescriptionActive]}>
{mode.description}
</Text>
)}
@@ -81,10 +81,13 @@ export function NameHostModal({ visible, serverId, hostname, onSkip, onSave }: N
}, [label, onSave, onSkip]);
return (
<AdaptiveModalSheet title="Name this host" visible={visible} onClose={onSkip} testID="name-host-modal">
<Text style={styles.helper}>
Optional. You can rename this later in Settings.
</Text>
<AdaptiveModalSheet
title="Name this host"
visible={visible}
onClose={onSkip}
testID="name-host-modal"
>
<Text style={styles.helper}>Optional. You can rename this later in Settings.</Text>
<View style={styles.field}>
<Text style={styles.label}>Label</Text>
@@ -48,15 +48,25 @@ export interface PairLinkModalProps {
onClose: () => void;
targetServerId?: string;
onCancel?: () => void;
onSaved?: (result: { profile: HostProfile; serverId: string; hostname: string | null; isNewHost: boolean }) => void;
onSaved?: (result: {
profile: HostProfile;
serverId: string;
hostname: string | null;
isNewHost: boolean;
}) => void;
}
export function PairLinkModal({ visible, onClose, onCancel, onSaved, targetServerId }: PairLinkModalProps) {
export function PairLinkModal({
visible,
onClose,
onCancel,
onSaved,
targetServerId,
}: PairLinkModalProps) {
const { theme } = useUnistyles();
const daemons = useHosts();
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const [offerUrl, setOfferUrl] = useState("");
const [isSaving, setIsSaving] = useState(false);
@@ -148,10 +158,24 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved, targetServe
} finally {
setIsSaving(false);
}
}, [daemons, handleClose, isMobile, isSaving, offerUrl, onSaved, targetServerId, upsertDaemonFromOfferUrl]);
}, [
daemons,
handleClose,
isMobile,
isSaving,
offerUrl,
onSaved,
targetServerId,
upsertDaemonFromOfferUrl,
]);
return (
<AdaptiveModalSheet title="Paste pairing link" visible={visible} onClose={handleClose} testID="pair-link-modal">
<AdaptiveModalSheet
title="Paste pairing link"
visible={visible}
onClose={handleClose}
testID="pair-link-modal"
>
<Text style={styles.helper}>Paste the pairing link from your server.</Text>
<View style={styles.field}>
@@ -1,13 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Modal,
Pressable,
ScrollView,
Text,
TextInput,
View,
Platform,
} from "react-native";
import { Modal, Pressable, ScrollView, Text, TextInput, View, Platform } from "react-native";
import { Folder } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useQuery } from "@tanstack/react-query";
@@ -37,7 +29,7 @@ export function ProjectPickerModal() {
const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const workspaces = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.workspaces : undefined
serverId ? state.sessions[serverId]?.workspaces : undefined,
);
const inputRef = useRef<TextInput>(null);
@@ -49,7 +41,7 @@ export function ProjectPickerModal() {
const recommendedPaths = useMemo(() => {
if (!workspaces) return [];
return Array.from(workspaces.values()).map(
(workspace) => workspace.projectRootPath || workspace.id
(workspace) => workspace.projectRootPath || workspace.id,
);
}, [workspaces]);
@@ -64,9 +56,7 @@ export function ProjectPickerModal() {
limit: 30,
});
return (
result.entries?.flatMap((entry) =>
entry.kind === "directory" ? [entry.path] : []
) ?? []
result.entries?.flatMap((entry) => (entry.kind === "directory" ? [entry.path] : [])) ?? []
);
},
enabled: Boolean(client) && isConnected && open,
@@ -81,7 +71,7 @@ export function ProjectPickerModal() {
serverPaths: directorySuggestionsQuery.data ?? [],
query,
}),
[query, directorySuggestionsQuery.data, recommendedPaths]
[query, directorySuggestionsQuery.data, recommendedPaths],
);
const handleClose = useCallback(() => {
@@ -103,7 +93,7 @@ export function ProjectPickerModal() {
setIsSubmitting(false);
}
},
[client, openProject, serverId, setOpen]
[client, openProject, serverId, setOpen],
);
const handleSubmitCustom = useCallback(() => {
@@ -136,13 +126,7 @@ export function ProjectPickerModal() {
function handler(event: KeyboardEvent) {
const key = event.key;
if (
key !== "ArrowDown" &&
key !== "ArrowUp" &&
key !== "Enter" &&
key !== "Escape"
)
return;
if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") return;
if (key === "Escape") {
event.preventDefault();
@@ -180,12 +164,7 @@ export function ProjectPickerModal() {
if (!serverId) return null;
return (
<Modal
visible={open}
transparent
animationType="fade"
onRequestClose={handleClose}
>
<Modal visible={open} transparent animationType="fade" onRequestClose={handleClose}>
<View style={styles.overlay}>
<Pressable style={styles.backdrop} onPress={handleClose} />
@@ -198,9 +177,7 @@ export function ProjectPickerModal() {
},
]}
>
<View
style={[styles.header, { borderBottomColor: theme.colors.border }]}
>
<View style={[styles.header, { borderBottomColor: theme.colors.border }]}>
<TextInput
ref={inputRef}
value={query}
@@ -225,21 +202,11 @@ export function ProjectPickerModal() {
showsVerticalScrollIndicator={false}
>
{isSubmitting ? (
<Text
style={[
styles.emptyText,
{ color: theme.colors.foregroundMuted },
]}
>
<Text style={[styles.emptyText, { color: theme.colors.foregroundMuted }]}>
Opening project...
</Text>
) : options.length === 0 && !query.trim() ? (
<Text
style={[
styles.emptyText,
{ color: theme.colors.foregroundMuted },
]}
>
<Text style={[styles.emptyText, { color: theme.colors.foregroundMuted }]}>
Start typing a path
</Text>
) : (
@@ -266,10 +233,7 @@ export function ProjectPickerModal() {
/>
</View>
<Text
style={[
styles.rowText,
{ color: theme.colors.foreground },
]}
style={[styles.rowText, { color: theme.colors.foreground }]}
numberOfLines={1}
>
{shortenPath(path)}
@@ -1,12 +1,12 @@
import { Bot } from 'lucide-react-native'
import { ClaudeIcon } from '@/components/icons/claude-icon'
import { CodexIcon } from '@/components/icons/codex-icon'
import { Bot } from "lucide-react-native";
import { ClaudeIcon } from "@/components/icons/claude-icon";
import { CodexIcon } from "@/components/icons/codex-icon";
const PROVIDER_ICONS: Record<string, typeof Bot> = {
claude: ClaudeIcon as unknown as typeof Bot,
codex: CodexIcon as unknown as typeof Bot,
}
};
export function getProviderIcon(provider: string): typeof Bot {
return PROVIDER_ICONS[provider] ?? Bot
return PROVIDER_ICONS[provider] ?? Bot;
}
@@ -1,12 +1,5 @@
import { useState, useCallback } from "react";
import {
View,
Text,
TextInput,
Pressable,
ActivityIndicator,
Platform,
} from "react-native";
import { View, Text, TextInput, Pressable, ActivityIndicator, Platform } from "react-native";
import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles";
import { Check, CircleHelp, X } from "lucide-react-native";
import type { PendingPermission } from "@/types/shared";
@@ -68,52 +61,42 @@ interface QuestionFormCardProps {
const IS_WEB = Platform.OS === "web";
export function QuestionFormCard({
permission,
onRespond,
isResponding,
}: QuestionFormCardProps) {
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
const { theme } = useUnistyles();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const questions = parseQuestions(permission.request.input);
const [selections, setSelections] = useState<Record<number, Set<number>>>({});
const [otherTexts, setOtherTexts] = useState<Record<number, string>>({});
const [respondingAction, setRespondingAction] = useState<
"submit" | "dismiss" | null
>(null);
const [respondingAction, setRespondingAction] = useState<"submit" | "dismiss" | null>(null);
const toggleOption = useCallback(
(qIndex: number, optIndex: number, multiSelect: boolean) => {
setSelections((prev) => {
const current = prev[qIndex] ?? new Set<number>();
const next = new Set(current);
if (multiSelect) {
if (next.has(optIndex)) {
next.delete(optIndex);
} else {
next.add(optIndex);
}
const toggleOption = useCallback((qIndex: number, optIndex: number, multiSelect: boolean) => {
setSelections((prev) => {
const current = prev[qIndex] ?? new Set<number>();
const next = new Set(current);
if (multiSelect) {
if (next.has(optIndex)) {
next.delete(optIndex);
} else {
if (next.has(optIndex)) {
next.clear();
} else {
next.clear();
next.add(optIndex);
}
next.add(optIndex);
}
return { ...prev, [qIndex]: next };
});
setOtherTexts((prev) => {
if (!prev[qIndex]) return prev;
const next = { ...prev };
delete next[qIndex];
return next;
});
},
[]
);
} else {
if (next.has(optIndex)) {
next.clear();
} else {
next.clear();
next.add(optIndex);
}
}
return { ...prev, [qIndex]: next };
});
setOtherTexts((prev) => {
if (!prev[qIndex]) return prev;
const next = { ...prev };
delete next[qIndex];
return next;
});
}, []);
const setOtherText = useCallback((qIndex: number, text: string) => {
setOtherTexts((prev) => ({ ...prev, [qIndex]: text }));
@@ -182,9 +165,7 @@ export function QuestionFormCard({
return (
<View key={qIndex} style={styles.questionBlock}>
<View style={styles.questionHeader}>
<Text
style={[styles.questionText, { color: theme.colors.foreground }]}
>
<Text style={[styles.questionText, { color: theme.colors.foreground }]}>
{q.question}
</Text>
<CircleHelp size={14} color={theme.colors.foregroundMuted} />
@@ -202,19 +183,12 @@ export function QuestionFormCard({
},
pressed && styles.optionItemPressed,
]}
onPress={() =>
toggleOption(qIndex, optIndex, q.multiSelect)
}
onPress={() => toggleOption(qIndex, optIndex, q.multiSelect)}
disabled={isResponding}
>
<View style={styles.optionItemContent}>
<View style={styles.optionTextBlock}>
<Text
style={[
styles.optionLabel,
{ color: theme.colors.foreground },
]}
>
<Text style={[styles.optionLabel, { color: theme.colors.foreground }]}>
{opt.label}
</Text>
{opt.description ? (
@@ -230,10 +204,7 @@ export function QuestionFormCard({
</View>
{isSelected ? (
<View style={styles.optionCheckSlot}>
<Check
size={16}
color={theme.colors.foregroundMuted}
/>
<Check size={16} color={theme.colors.foregroundMuted} />
</View>
) : null}
</View>
@@ -245,9 +216,8 @@ export function QuestionFormCard({
style={[
styles.otherInput,
{
borderColor: otherText.length > 0
? theme.colors.borderAccent
: theme.colors.border,
borderColor:
otherText.length > 0 ? theme.colors.borderAccent : theme.colors.border,
color: theme.colors.foreground,
backgroundColor: theme.colors.surface2,
},
@@ -264,19 +234,12 @@ export function QuestionFormCard({
);
})}
<View
style={[
styles.actionsContainer,
!isMobile && styles.actionsContainerDesktop,
]}
>
<View style={[styles.actionsContainer, !isMobile && styles.actionsContainerDesktop]}>
<Pressable
style={({ pressed, hovered = false }) => [
styles.actionButton,
{
backgroundColor: hovered
? theme.colors.surface2
: theme.colors.surface1,
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
borderColor: theme.colors.borderAccent,
},
pressed && styles.optionItemPressed,
@@ -285,19 +248,11 @@ export function QuestionFormCard({
disabled={isResponding}
>
{respondingAction === "dismiss" ? (
<ActivityIndicator
size="small"
color={theme.colors.foregroundMuted}
/>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
) : (
<View style={styles.actionContent}>
<X size={14} color={theme.colors.foregroundMuted} />
<Text
style={[
styles.actionText,
{ color: theme.colors.foregroundMuted },
]}
>
<Text style={[styles.actionText, { color: theme.colors.foregroundMuted }]}>
Dismiss
</Text>
</View>
@@ -311,12 +266,8 @@ export function QuestionFormCard({
styles.actionButton,
{
backgroundColor:
hovered && !disabled
? theme.colors.surface2
: theme.colors.surface1,
borderColor: disabled
? theme.colors.border
: theme.colors.borderAccent,
hovered && !disabled ? theme.colors.surface2 : theme.colors.surface1,
borderColor: disabled ? theme.colors.border : theme.colors.borderAccent,
opacity: disabled ? 0.5 : 1,
},
pressed && !disabled ? styles.optionItemPressed : null,
@@ -326,27 +277,18 @@ export function QuestionFormCard({
disabled={!allAnswered || isResponding}
>
{respondingAction === "submit" ? (
<ActivityIndicator
size="small"
color={theme.colors.foreground}
/>
<ActivityIndicator size="small" color={theme.colors.foreground} />
) : (
<View style={styles.actionContent}>
<Check
size={14}
color={
allAnswered
? theme.colors.foreground
: theme.colors.foregroundMuted
}
color={allAnswered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text
style={[
styles.actionText,
{
color: allAnswered
? theme.colors.foreground
: theme.colors.foregroundMuted,
color: allAnswered ? theme.colors.foreground : theme.colors.foregroundMuted,
},
]}
>
@@ -73,8 +73,7 @@ export function ResizeHandle({
return;
}
const pointerCurrent =
direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY;
const pointerCurrent = direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY;
const deltaRatio =
(pointerCurrent - pointerState.pointerStart) / pointerState.containerSize;
@@ -91,7 +90,7 @@ export function ResizeHandle({
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp, { once: true });
},
[direction, groupId, index, onResizeSplit, sizes]
[direction, groupId, index, onResizeSplit, sizes],
);
return (
@@ -107,9 +106,7 @@ export function ResizeHandle({
pointerEvents="none"
style={[
styles.highlight,
direction === "horizontal"
? styles.highlightHorizontal
: styles.highlightVertical,
direction === "horizontal" ? styles.highlightHorizontal : styles.highlightVertical,
{ backgroundColor: theme.colors.accent },
]}
/>
@@ -2,13 +2,7 @@ import { useEffect, useRef } from "react";
import { Animated, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
function SkeletonPulse({
pulse,
style,
}: {
pulse: Animated.Value;
style: StyleProp<ViewStyle>;
}) {
function SkeletonPulse({ pulse, style }: { pulse: Animated.Value; style: StyleProp<ViewStyle> }) {
const opacity = pulse.interpolate({
inputRange: [0, 1],
outputRange: [0.4, 0.8],
@@ -33,7 +27,7 @@ export function SidebarAgentListSkeleton() {
duration: 1000,
useNativeDriver: true,
}),
])
]),
);
animation.start();
@@ -55,10 +49,7 @@ export function SidebarAgentListSkeleton() {
<View style={styles.rows}>
{Array.from({ length: 3 }).map((__, rowIdx) => (
<View
key={`skeleton-row-${sectionIdx}-${rowIdx}`}
style={styles.row}
>
<View key={`skeleton-row-${sectionIdx}-${rowIdx}`} style={styles.row}>
<SkeletonPulse pulse={pulse} style={styles.rowDot} />
<SkeletonPulse pulse={pulse} style={styles.rowTitle} />
<SkeletonPulse pulse={pulse} style={styles.rowBadge} />
File diff suppressed because it is too large Load Diff
@@ -5,4 +5,3 @@
// Metro's platform-specific extensions will pick the right one at runtime.
export * from "./sortable-inline-list.native";
@@ -66,7 +66,7 @@ function SortableItem<T>({
const baseTransform = externalDndContext
? undefined
: CSS.Transform.toString(
transform && isDragging ? { ...transform, scaleX: 1, scaleY: 1 } : transform
transform && isDragging ? { ...transform, scaleX: 1, scaleY: 1 } : transform,
);
const scaleTransform = !externalDndContext && isDragging ? "scale(1.01)" : "";
const combinedTransform = [baseTransform, scaleTransform].filter(Boolean).join(" ");
@@ -87,9 +87,7 @@ function SortableItem<T>({
? {
attributes: attributes as unknown as Record<string, unknown>,
listeners: listeners as unknown as Record<string, unknown>,
setActivatorNodeRef: setActivatorNodeRef as unknown as (
node: unknown
) => void,
setActivatorNodeRef: setActivatorNodeRef as unknown as (node: unknown) => void,
}
: undefined,
};
@@ -132,7 +130,7 @@ export function SortableInlineList<T>({
}): ReactElement {
const [activeId, setActiveId] = useState<string | null>(null);
const [dragItems, setDragItems] = useState<T[] | null>(null);
const items = externalDndContext ? data : dragItems ?? data;
const items = externalDndContext ? data : (dragItems ?? data);
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -142,7 +140,7 @@ export function SortableInlineList<T>({
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
}),
);
const handleDragStart = useCallback(
@@ -154,7 +152,7 @@ export function SortableInlineList<T>({
setActiveId(String(event.active.id));
onDragBegin?.();
},
[data, disabled, onDragBegin]
[data, disabled, onDragBegin],
);
const handleDragEnd = useCallback(
@@ -169,21 +167,16 @@ export function SortableInlineList<T>({
}
if (over && active.id !== over.id) {
const oldIndex = items.findIndex(
(item, i) => keyExtractor(item, i) === active.id
);
const newIndex = items.findIndex(
(item, i) => keyExtractor(item, i) === over.id
);
const oldIndex = items.findIndex((item, i) => keyExtractor(item, i) === active.id);
const newIndex = items.findIndex((item, i) => keyExtractor(item, i) === over.id);
if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) {
const newItems = arrayMove(items, oldIndex, newIndex);
onDragEnd?.(newItems);
}
}
},
[disabled, items, keyExtractor, onDragEnd]
[disabled, items, keyExtractor, onDragEnd],
);
const ids = items.map((item, index) => keyExtractor(item, index));
@@ -5,13 +5,13 @@ describe("shouldFocusPaneFromEventTarget", () => {
it("returns false for links and buttons", () => {
expect(
shouldFocusPaneFromEventTarget({
closest: () => ({ tagName: "A" } as Element),
} as unknown as EventTarget)
closest: () => ({ tagName: "A" }) as Element,
} as unknown as EventTarget),
).toBe(false);
expect(
shouldFocusPaneFromEventTarget({
closest: () => ({ tagName: "BUTTON" } as Element),
} as unknown as EventTarget)
closest: () => ({ tagName: "BUTTON" }) as Element,
} as unknown as EventTarget),
).toBe(false);
});
@@ -19,7 +19,7 @@ describe("shouldFocusPaneFromEventTarget", () => {
expect(
shouldFocusPaneFromEventTarget({
closest: () => null,
} as unknown as EventTarget)
} as unknown as EventTarget),
).toBe(true);
expect(shouldFocusPaneFromEventTarget(null)).toBe(true);
});
@@ -27,7 +27,7 @@ describe("computeTabDropPreview", () => {
targetTabs,
activeRect: { left: 180, width: 40 },
overRect: { left: 200, width: 100 },
})
}),
).toEqual({
paneId: "target",
insertionIndex: 2,
@@ -45,7 +45,7 @@ describe("computeTabDropPreview", () => {
targetTabs,
activeRect: { left: 280, width: 40 },
overRect: { left: 200, width: 100 },
})
}),
).toEqual({
paneId: "target",
insertionIndex: 3,
@@ -63,7 +63,7 @@ describe("computeTabDropPreview", () => {
targetTabs,
activeRect: { left: 460, width: 40 },
overRect: { left: 400, width: 100 },
})
}),
).toEqual({
paneId: "pane",
insertionIndex: 3,
@@ -22,9 +22,7 @@ interface ComputeTabDropPreviewInput {
};
}
export function computeTabDropPreview(
input: ComputeTabDropPreviewInput
): TabDropPreview | null {
export function computeTabDropPreview(input: ComputeTabDropPreviewInput): TabDropPreview | null {
const targetIndex = input.targetTabs.findIndex((tab) => tab.tabId === input.overTabId);
if (targetIndex < 0 || input.overRect.width <= 0) {
return null;
+64 -50
View File
@@ -1,4 +1,14 @@
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type ReactNode, type SetStateAction } from "react";
import {
Fragment,
useCallback,
useEffect,
useMemo,
useRef,
useState,
type Dispatch,
type ReactNode,
type SetStateAction,
} from "react";
import {
DndContext,
DragOverlay,
@@ -45,7 +55,12 @@ import {
WorkspaceTabIcon,
} from "@/screens/workspace/workspace-tab-presentation";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import { useWorkspaceLayoutStore, type SplitNode, type SplitPane, type WorkspaceLayout } from "@/stores/workspace-layout-store";
import {
useWorkspaceLayoutStore,
type SplitNode,
type SplitPane,
type WorkspaceLayout,
} from "@/stores/workspace-layout-store";
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
interface SplitContainerProps {
@@ -65,7 +80,10 @@ interface SplitContainerProps {
onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onSelectNewTabOption: (selection: { optionId: "__new_tab_agent__" | "__new_tab_terminal__"; paneId?: string }) => void;
onSelectNewTabOption: (selection: {
optionId: "__new_tab_agent__" | "__new_tab_terminal__";
paneId?: string;
}) => void;
onNewTerminalTab: (input: { paneId?: string }) => void;
newTabAgentOptionId?: "__new_tab_agent__" | "__new_tab_terminal__";
buildPaneContentModel: (input: {
@@ -100,8 +118,7 @@ interface SplitPaneDropData {
paneId: string;
}
interface SplitNodeViewProps
extends Omit<SplitContainerProps, "layout"> {
interface SplitNodeViewProps extends Omit<SplitContainerProps, "layout"> {
node: SplitNode;
uiTabs: WorkspaceTab[];
focusedPaneId: string;
@@ -135,14 +152,14 @@ interface SplitPaneViewProps
const dropCollisionDetection: CollisionDetection = (args) => {
const pointerHits = pointerWithin(args);
const tabHits = pointerHits.filter(
(entry) => entry.data?.droppableContainer.data.current?.kind === "workspace-tab"
(entry) => entry.data?.droppableContainer.data.current?.kind === "workspace-tab",
);
if (tabHits.length > 0) {
return tabHits;
}
const paneHits = pointerHits.filter(
(entry) => entry.data?.droppableContainer.data.current?.kind === "split-pane-drop"
(entry) => entry.data?.droppableContainer.data.current?.kind === "split-pane-drop",
);
if (paneHits.length > 0) {
return paneHits;
@@ -192,7 +209,7 @@ export function SplitContainer({
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
}),
);
const panesById = useMemo(() => collectPanesById(layout.root), [layout.root]);
@@ -215,11 +232,7 @@ export function SplitContainer({
}, []);
const updateDropPreview = useCallback(
(
event:
| Pick<DragMoveEvent, "active" | "over">
| Pick<DragOverEvent, "active" | "over">
) => {
(event: Pick<DragMoveEvent, "active" | "over"> | Pick<DragOverEvent, "active" | "over">) => {
const activeData = event.active.data.current as WorkspaceTabDragData | undefined;
const overData = event.over?.data.current as
| WorkspaceTabDragData
@@ -268,7 +281,7 @@ export function SplitContainer({
left: overRect.left,
width: overRect.width,
},
})
}),
);
return;
}
@@ -305,7 +318,7 @@ export function SplitContainer({
}),
});
},
[panesById, uiTabs]
[panesById, uiTabs],
);
const handleDragEnd = useCallback(
@@ -346,8 +359,15 @@ export function SplitContainer({
if (activeData.paneId === overData.paneId) {
if (sourceIndex !== resolvedTabDropPreview.insertionIndex) {
const nextTabs = arrayMove(sourceTabs, sourceIndex, resolvedTabDropPreview.insertionIndex);
onReorderTabsInPane(activeData.paneId, nextTabs.map((tab) => tab.tabId));
const nextTabs = arrayMove(
sourceTabs,
sourceIndex,
resolvedTabDropPreview.insertionIndex,
);
onReorderTabsInPane(
activeData.paneId,
nextTabs.map((tab) => tab.tabId),
);
}
setDropPreview(null);
setTabDropPreview(null);
@@ -383,7 +403,15 @@ export function SplitContainer({
setDropPreview(null);
setTabDropPreview(null);
},
[dropPreview, onMoveTabToPane, onReorderTabsInPane, onSplitPane, panesById, tabDropPreview, uiTabs]
[
dropPreview,
onMoveTabToPane,
onReorderTabsInPane,
onSplitPane,
panesById,
tabDropPreview,
uiTabs,
],
);
return (
@@ -492,8 +520,7 @@ function DragOverlayTabChipInner({
workspaceId={normalizedWorkspaceId}
>
{(presentation) => {
const label =
presentation.titleState === "loading" ? "Loading..." : presentation.label;
const label = presentation.titleState === "loading" ? "Loading..." : presentation.label;
return (
<View
@@ -508,10 +535,7 @@ function DragOverlayTabChipInner({
<WorkspaceTabIcon presentation={presentation} active size={14} />
<Text
numberOfLines={1}
style={[
styles.dragOverlayLabel,
{ color: theme.colors.foreground },
]}
style={[styles.dragOverlayLabel, { color: theme.colors.foreground }]}
>
{label}
</Text>
@@ -593,8 +617,9 @@ function SplitNodeView({
}
const groupSizes =
useWorkspaceLayoutStore((state) => state.splitSizesByWorkspace[workspaceKey]?.[node.group.id]) ??
node.group.sizes;
useWorkspaceLayoutStore(
(state) => state.splitSizesByWorkspace[workspaceKey]?.[node.group.id],
) ?? node.group.sizes;
return (
<View
@@ -695,12 +720,9 @@ function SplitPaneView({
pane,
tabs: uiTabs,
}),
[pane, uiTabs]
);
const paneTabs = useMemo(
() => paneState.tabs.map((tab) => tab.descriptor),
[paneState.tabs]
[pane, uiTabs],
);
const paneTabs = useMemo(() => paneState.tabs.map((tab) => tab.descriptor), [paneState.tabs]);
const activeTabDescriptor = paneState.activeTab?.descriptor ?? null;
const desktopTabRowItems = useMemo<WorkspaceDesktopTabRowItem[]>(
() =>
@@ -710,12 +732,7 @@ function SplitPaneView({
isCloseHovered: hoveredCloseTabKey === tab.key,
isClosingTab: closingTabIds.has(tab.tabId),
})),
[
activeTabDescriptor?.key,
closingTabIds,
hoveredCloseTabKey,
paneTabs,
]
[activeTabDescriptor?.key, closingTabIds, hoveredCloseTabKey, paneTabs],
);
const paneContent = useMemo(
() =>
@@ -726,11 +743,7 @@ function SplitPaneView({
tab: activeTabDescriptor,
})
: null,
[
activeTabDescriptor,
buildPaneContentModel,
pane.id,
]
[activeTabDescriptor, buildPaneContentModel, pane.id],
);
useEffect(() => {
@@ -771,11 +784,7 @@ function SplitPaneView({
}, [onFocusPane, pane.id]);
return (
<View
ref={paneRef}
collapsable={false}
style={styles.pane}
>
<View ref={paneRef} collapsable={false} style={styles.pane}>
<View style={styles.paneTabs}>
<WorkspaceDesktopTabsRow
paneId={pane.id}
@@ -796,13 +805,18 @@ function SplitPaneView({
onNewTerminalTab={onNewTerminalTab}
newTabAgentOptionId={newTabAgentOptionId ?? "__new_tab_agent__"}
onReorderTabs={(nextTabs) => {
onReorderTabsInPane(pane.id, nextTabs.map((tab) => tab.tabId));
onReorderTabsInPane(
pane.id,
nextTabs.map((tab) => tab.tabId),
);
}}
onSplitRight={() => onSplitPaneEmpty({ targetPaneId: pane.id, position: "right" })}
onSplitDown={() => onSplitPaneEmpty({ targetPaneId: pane.id, position: "bottom" })}
externalDndContext
activeDragTabId={activeDragTabId}
tabDropPreviewIndex={tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null}
tabDropPreviewIndex={
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
}
/>
</View>
@@ -810,7 +824,7 @@ function SplitPaneView({
{paneContent ? (
<WorkspacePaneContent content={paneContent} />
) : (
renderPaneEmptyState?.() ?? null
(renderPaneEmptyState?.() ?? null)
)}
<SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} />
</View>
@@ -23,11 +23,7 @@ export function buildSplitDropZoneId(paneId: string): string {
return `split-pane-drop:${paneId}`;
}
export function SplitDropZone({
paneId,
active,
preview,
}: SplitDropZoneProps) {
export function SplitDropZone({ paneId, active, preview }: SplitDropZoneProps) {
const { theme } = useUnistyles();
const { setNodeRef } = useDroppable({
id: buildSplitDropZoneId(paneId),
@@ -66,11 +62,7 @@ export function SplitDropZone({
}
return (
<View
ref={setNodeRef as any}
style={styles.overlay}
pointerEvents="none"
>
<View ref={setNodeRef as any} style={styles.overlay} pointerEvents="none">
{previewStyles ? (
<>
<View pointerEvents="none" style={previewStyles.overlay} />
@@ -89,10 +81,8 @@ export function resolveSplitDropPosition(input: {
}): SplitDropZonePosition {
const centerInsetX = input.width * ((1 - CENTER_RATIO) / 2);
const centerInsetY = input.height * ((1 - CENTER_RATIO) / 2);
const insideCenterX =
input.x >= centerInsetX && input.x <= input.width - centerInsetX;
const insideCenterY =
input.y >= centerInsetY && input.y <= input.height - centerInsetY;
const insideCenterX = input.x >= centerInsetX && input.x <= input.width - centerInsetX;
const insideCenterY = input.y >= centerInsetY && input.y <= input.height - centerInsetY;
if (insideCenterX && insideCenterY) {
return "center";
@@ -9,11 +9,7 @@ import {
} from "react-native";
import type { StreamItem } from "@/types/stream";
import { useBottomAnchorController } from "./use-bottom-anchor-controller";
import type {
StreamRenderInput,
StreamStrategy,
StreamViewportHandle,
} from "./stream-strategy";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./stream-strategy";
import {
createStreamStrategy,
isNearBottomForStreamRenderStrategy,
@@ -92,7 +88,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
strategy,
isViewportSettling: isNativeViewportSettling,
}),
[isNativeViewportSettling, strategy]
[isNativeViewportSettling, strategy],
);
const scrollToBottom = useCallback(
@@ -109,7 +105,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
};
onNearBottomChange(true);
},
[onNearBottomChange]
[onNearBottomChange],
);
const bottomAnchorController = useBottomAnchorController({
@@ -159,7 +155,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const subscriptions = keyboardEvents.map((eventName) =>
Keyboard.addListener(eventName, () => {
markNativeViewportSettling();
})
}),
);
return () => {
for (const subscription of subscriptions) {
@@ -228,7 +224,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
});
}
},
[bottomAnchorController, onNearBottomChange, strategy]
[bottomAnchorController, onNearBottomChange, strategy],
);
const handleListLayout = useCallback(
@@ -257,7 +253,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
viewportHeight,
});
},
[bottomAnchorController, markNativeViewportSettling]
[bottomAnchorController, markNativeViewportSettling],
);
const handleContentSizeChange = useCallback(
@@ -275,7 +271,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
contentHeight: nextContentHeight,
});
},
[bottomAnchorController]
[bottomAnchorController],
);
const renderItem = useCallback(
@@ -283,7 +279,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const rendered = renderers.renderHistoryMountedRow(item, index, historyRows);
return rendered ? <Fragment>{rendered}</Fragment> : null;
},
[historyRows, renderers]
[historyRows, renderers],
);
const liveHeaderContent = useMemo(() => {
@@ -336,12 +332,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
export function createNativeStreamStrategy(): StreamStrategy {
const strategy = createStreamStrategy({
render: (renderInput) => (
<NativeStreamViewport
{...renderInput}
strategy={strategy}
/>
),
render: (renderInput) => <NativeStreamViewport {...renderInput} strategy={strategy} />,
orderTailReverse: true,
orderHeadReverse: true,
assistantTurnTraversalStep: 1,
File diff suppressed because it is too large Load Diff
+16 -44
View File
@@ -1,10 +1,7 @@
import type { ComponentType, ReactElement, ReactNode, RefObject } from "react";
import type { StyleProp, ViewStyle } from "react-native";
import type { StreamItem } from "@/types/stream";
import type {
StreamHistoryBoundary,
StreamRenderSegments,
} from "./agent-stream-render-model";
import type { StreamHistoryBoundary, StreamRenderSegments } from "./agent-stream-render-model";
import type {
BottomAnchorLocalRequest,
BottomAnchorRouteRequest,
@@ -49,21 +46,9 @@ export type StreamViewportHandle = {
};
export type StreamSegmentRenderers = {
renderHistoryVirtualizedRow: (
item: StreamItem,
index: number,
items: StreamItem[]
) => ReactNode;
renderHistoryMountedRow: (
item: StreamItem,
index: number,
items: StreamItem[]
) => ReactNode;
renderLiveHeadRow: (
item: StreamItem,
index: number,
items: StreamItem[]
) => ReactNode;
renderHistoryVirtualizedRow: (item: StreamItem, index: number, items: StreamItem[]) => ReactNode;
renderHistoryMountedRow: (item: StreamItem, index: number, items: StreamItem[]) => ReactNode;
renderLiveHeadRow: (item: StreamItem, index: number, items: StreamItem[]) => ReactNode;
renderLiveAuxiliary: () => ReactNode;
};
@@ -96,18 +81,16 @@ export interface StreamStrategy {
getNeighborItem: (
items: StreamItem[],
index: number,
relation: NeighborRelation
relation: NeighborRelation,
) => StreamItem | undefined;
collectAssistantTurnContent: (items: StreamItem[], startIndex: number) => string;
isNearBottom: (input: StreamNearBottomInput) => boolean;
getBottomOffset: (metrics: StreamViewportMetrics) => number;
getEdgeSlotProps: (
component: ReactElement | ComponentType<any> | null,
gapSize: number
gapSize: number,
) => StreamEdgeSlotProps;
getMaintainVisibleContentPosition: () =>
| MaintainVisibleContentPositionConfig
| undefined;
getMaintainVisibleContentPosition: () => MaintainVisibleContentPositionConfig | undefined;
getBottomAnchorTransportBehavior: () => BottomAnchorTransportBehavior;
getFlatListInverted: () => boolean;
getOverlayScrollbarInverted: () => boolean;
@@ -137,15 +120,12 @@ type StreamStrategyConfig = {
const NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES = 4;
export function createStreamStrategy(
config: StreamStrategyConfig
): StreamStrategy {
export function createStreamStrategy(config: StreamStrategyConfig): StreamStrategy {
return {
render: config.render,
orderTail: (streamItems) =>
config.orderTailReverse ? [...streamItems].reverse() : streamItems,
orderHead: (streamHead) =>
config.orderHeadReverse ? [...streamHead].reverse() : streamHead,
orderHead: (streamHead) => (config.orderHeadReverse ? [...streamHead].reverse() : streamHead),
getNeighborIndex: (index, relation) =>
relation === "above"
? index + config.assistantTurnTraversalStep
@@ -197,15 +177,14 @@ export function createStreamStrategy(
getOverlayScrollbarInverted: () => config.overlayScrollbarInverted,
shouldDisableParentScrollOnInlineDetailsExpansion: () =>
config.disableParentScrollOnInlineDetailsExpansion,
shouldAnchorBottomOnContentSizeChange: () =>
config.anchorBottomOnContentSizeChange,
shouldAnchorBottomOnContentSizeChange: () => config.anchorBottomOnContentSizeChange,
shouldAnimateManualScrollToBottom: () => config.animateManualScrollToBottom,
shouldUseVirtualizedList: () => config.useVirtualizedList,
};
}
export function resolveStreamRenderStrategy(
input: ResolveStreamRenderStrategyInput
input: ResolveStreamRenderStrategyInput,
): StreamStrategy {
if (input.platform === "web") {
return createWebStreamStrategy({
@@ -226,7 +205,7 @@ export function resolveBottomAnchorTransportBehavior(input: {
return {
verificationDelayFrames: Math.max(
baseBehavior.verificationDelayFrames,
NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES
NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES,
),
verificationRetryMode: "recheck",
};
@@ -260,11 +239,7 @@ export function getStreamNeighborItem(params: {
index: number;
relation: NeighborRelation;
}): StreamItem | undefined {
return params.strategy.getNeighborItem(
params.items,
params.index,
params.relation
);
return params.strategy.getNeighborItem(params.items, params.index, params.relation);
}
export function collectAssistantTurnContentForStreamRenderStrategy(params: {
@@ -272,14 +247,11 @@ export function collectAssistantTurnContentForStreamRenderStrategy(params: {
items: StreamItem[];
startIndex: number;
}): string {
return params.strategy.collectAssistantTurnContent(
params.items,
params.startIndex
);
return params.strategy.collectAssistantTurnContent(params.items, params.startIndex);
}
export function isNearBottomForStreamRenderStrategy(
params: StreamNearBottomInput & { strategy: StreamStrategy }
params: StreamNearBottomInput & { strategy: StreamStrategy },
): boolean {
return params.strategy.isNearBottom({
offsetY: params.offsetY,
@@ -292,7 +264,7 @@ export function isNearBottomForStreamRenderStrategy(
export function getBottomOffsetForStreamRenderStrategy(
params: StreamViewportMetrics & {
strategy: StreamStrategy;
}
},
): number {
return params.strategy.getBottomOffset({
contentHeight: params.contentHeight,
+5 -14
View File
@@ -26,8 +26,7 @@ function ensureSharedStepLoopStarted(): void {
}
sharedLoopStarted = true;
const elapsedMs =
(Date.now() - SYNCED_LOADER_EPOCH_MS) % SYNCED_LOADER_DURATION_MS;
const elapsedMs = (Date.now() - SYNCED_LOADER_EPOCH_MS) % SYNCED_LOADER_DURATION_MS;
sharedStepProgress.value = (elapsedMs / SYNCED_LOADER_DURATION_MS) * DOT_COUNT;
sharedStepProgress.value = withTiming(
DOT_COUNT,
@@ -47,19 +46,13 @@ function ensureSharedStepLoopStarted(): void {
easing: Easing.linear,
}),
-1,
false
false,
);
}
},
);
}
export function SyncedLoader({
size = 10,
color,
}: {
size?: number;
color: string;
}) {
export function SyncedLoader({ size = 10, color }: { size?: number; color: string }) {
useEffect(() => {
ensureSharedStepLoopStarted();
}, []);
@@ -94,9 +87,7 @@ export function SyncedLoader({
{Array.from({ length: DOT_COUNT }).map((_, dotIndex) => {
const rowIndex = Math.floor(dotIndex / GRID_COLUMNS);
const columnIndex = dotIndex % GRID_COLUMNS;
const sequenceIndex = DOT_SEQUENCE.indexOf(
dotIndex as (typeof DOT_SEQUENCE)[number]
);
const sequenceIndex = DOT_SEQUENCE.indexOf(dotIndex as (typeof DOT_SEQUENCE)[number]);
return (
<SpinnerDot
@@ -318,10 +318,7 @@ export default function TerminalEmulator({
initialOutputText,
theme: mountedThemeRef.current,
});
appliedChunkSequenceRef.current = Math.max(
0,
Math.floor(initialOutputChunkSequence)
);
appliedChunkSequenceRef.current = Math.max(0, Math.floor(initialOutputChunkSequence));
return () => {
runtime.unmount();
@@ -469,10 +466,7 @@ export default function TerminalEmulator({
}, [streamKey]);
useEffect(() => {
const maxScrollOffset = Math.max(
0,
viewportMetrics.contentSize - viewportMetrics.viewportSize
);
const maxScrollOffset = Math.max(0, viewportMetrics.contentSize - viewportMetrics.viewportSize);
const normalizedOffset = clamp(viewportMetrics.offset, 0, maxScrollOffset);
if (maxScrollOffset <= 0 || viewportMetrics.viewportSize <= 0) {
setIsScrollVisible(false);
@@ -504,11 +498,7 @@ export default function TerminalEmulator({
setIsScrollActive(false);
scrollActiveTimeoutRef.current = null;
}, SCROLLBAR_HANDLE_SCROLL_ACTIVE_MS);
}, [
viewportMetrics.contentSize,
viewportMetrics.offset,
viewportMetrics.viewportSize,
]);
}, [viewportMetrics.contentSize, viewportMetrics.offset, viewportMetrics.viewportSize]);
useEffect(() => {
return () => {
@@ -528,7 +518,7 @@ export default function TerminalEmulator({
contentSize: viewportMetrics.contentSize,
offset: viewportMetrics.offset,
}),
[viewportMetrics.contentSize, viewportMetrics.offset, viewportMetrics.viewportSize]
[viewportMetrics.contentSize, viewportMetrics.offset, viewportMetrics.viewportSize],
);
useEffect(() => {
@@ -569,11 +559,7 @@ export default function TerminalEmulator({
window.removeEventListener("pointerup", stopDragging);
window.removeEventListener("pointercancel", stopDragging);
};
}, [
isDraggingScrollbar,
scrollbarGeometry.maxHandleOffset,
scrollbarGeometry.maxScrollOffset,
]);
}, [isDraggingScrollbar, scrollbarGeometry.maxHandleOffset, scrollbarGeometry.maxScrollOffset]);
const handleVisible =
scrollbarGeometry.isVisible && (isDraggingScrollbar || isScrollVisible || isHandleHovered);
@@ -590,16 +576,13 @@ export default function TerminalEmulator({
: SCROLLBAR_HANDLE_WIDTH_IDLE;
const thumbRegionOffset = Math.max(
0,
scrollbarGeometry.handleOffset - SCROLLBAR_HANDLE_GRAB_VERTICAL_PADDING
scrollbarGeometry.handleOffset - SCROLLBAR_HANDLE_GRAB_VERTICAL_PADDING,
);
const thumbRegionHeight = Math.min(
viewportMetrics.viewportSize - thumbRegionOffset,
scrollbarGeometry.handleSize + SCROLLBAR_HANDLE_GRAB_VERTICAL_PADDING * 2
);
const handleInsetTop = Math.max(
0,
(thumbRegionHeight - scrollbarGeometry.handleSize) / 2
scrollbarGeometry.handleSize + SCROLLBAR_HANDLE_GRAB_VERTICAL_PADDING * 2,
);
const handleInsetTop = Math.max(0, (thumbRegionHeight - scrollbarGeometry.handleSize) / 2);
const handleTravelDurationMs =
isDraggingScrollbar || isScrollActive ? 0 : SCROLLBAR_HANDLE_TRAVEL_DURATION_MS;
@@ -677,7 +660,7 @@ export default function TerminalEmulator({
dragStartOffsetRef.current = clamp(
viewportMetrics.offset,
0,
scrollbarGeometry.maxScrollOffset
scrollbarGeometry.maxScrollOffset,
);
dragStartClientYRef.current = event.clientY;
setIsDraggingScrollbar(true);
@@ -703,8 +686,7 @@ export default function TerminalEmulator({
opacity: handleOpacity,
transitionProperty: "opacity, width, background-color",
transitionDuration: `${SCROLLBAR_HANDLE_FADE_DURATION_MS}ms, ${SCROLLBAR_HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${SCROLLBAR_HANDLE_FADE_DURATION_MS}ms`,
transitionTimingFunction:
"ease-out, cubic-bezier(0.22, 0.75, 0.2, 1), ease-out",
transitionTimingFunction: "ease-out, cubic-bezier(0.22, 0.75, 0.2, 1), ease-out",
}}
/>
</div>
+72 -134
View File
@@ -1,21 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useFocusEffect, useIsFocused } from "@react-navigation/native";
import {
ActivityIndicator,
Pressable,
ScrollView,
Text,
View,
} from "react-native";
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
import { Plus, X } from "lucide-react-native";
import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
import Svg, {
Defs,
LinearGradient as SvgLinearGradient,
Rect,
Stop,
} from "react-native-svg";
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import type { ListTerminalsResponse } from "@server/shared/messages";
import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input";
@@ -28,9 +17,7 @@ import {
} from "@/utils/terminal-keys";
import { upsertTerminalListEntry } from "@/utils/terminal-list";
import { confirmDialog } from "@/utils/confirm-dialog";
import {
getWorkspaceTerminalSession,
} from "@/terminal/runtime/workspace-terminal-session";
import { getWorkspaceTerminalSession } from "@/terminal/runtime/workspace-terminal-session";
import {
TerminalStreamController,
type TerminalStreamControllerStatus,
@@ -109,13 +96,7 @@ function TerminalCloseGradient({ color, gradientId }: { color: string; gradientI
<View style={styles.terminalTabCloseGradient} pointerEvents="none">
<Svg width="100%" height="100%" preserveAspectRatio="none">
<Defs>
<SvgLinearGradient
id={gradientId}
x1="0%"
y1="0%"
x2="100%"
y2="0%"
>
<SvgLinearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
<Stop offset="0%" stopColor={color} stopOpacity={0} />
<Stop offset="10%" stopColor={color} stopOpacity={1} />
<Stop offset="100%" stopColor={color} stopOpacity={1} />
@@ -138,8 +119,7 @@ export function TerminalPane({
const isScreenFocused = useIsFocused();
const { theme } = useUnistyles();
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme.colors.terminal]);
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView);
const openAgentList = usePanelStore((state) => state.openAgentList);
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
@@ -163,21 +143,18 @@ export function TerminalPane({
scopeKey,
maxOutputChars: MAX_OUTPUT_CHARS,
}),
[scopeKey]
[scopeKey],
);
const outputSession = workspaceTerminalSession.outputSession;
const subscribeOutputSession = useCallback(
(listener: () => void) => outputSession.subscribe(listener),
[outputSession]
);
const getOutputSessionState = useCallback(
() => outputSession.getState(),
[outputSession]
[outputSession],
);
const getOutputSessionState = useCallback(() => outputSession.getState(), [outputSession]);
const outputState = useSyncExternalStore(
subscribeOutputSession,
getOutputSessionState,
getOutputSessionState
getOutputSessionState,
);
const selectedOutputState = useMemo(() => {
if (outputState.selectedTerminalId === selectedTerminalId) {
@@ -204,9 +181,7 @@ export function TerminalPane({
const [focusRequestToken, setFocusRequestToken] = useState(0);
const [resizeRequestToken, setResizeRequestToken] = useState(0);
const [hoveredTerminalId, setHoveredTerminalId] = useState<string | null>(null);
const [hoveredCloseTerminalId, setHoveredCloseTerminalId] = useState<string | null>(
null
);
const [hoveredCloseTerminalId, setHoveredCloseTerminalId] = useState<string | null>(null);
const hoverOutTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const selectedTerminalIdRef = useRef<string | null>(selectedTerminalId);
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
@@ -217,7 +192,7 @@ export function TerminalPane({
(next: string | null) => {
onSelectedTerminalIdChange?.(next);
},
[onSelectedTerminalIdChange]
[onSelectedTerminalIdChange],
);
useEffect(() => {
@@ -237,7 +212,7 @@ export function TerminalPane({
clearHoverOutTimeout();
setHoveredTerminalId(terminalId);
},
[clearHoverOutTimeout]
[clearHoverOutTimeout],
);
const handleTerminalTabHoverOut = useCallback(
@@ -245,12 +220,10 @@ export function TerminalPane({
clearHoverOutTimeout();
hoverOutTimeoutRef.current = setTimeout(() => {
setHoveredTerminalId((current) => (current === terminalId ? null : current));
setHoveredCloseTerminalId((current) =>
current === terminalId ? null : current
);
setHoveredCloseTerminalId((current) => (current === terminalId ? null : current));
}, 50);
},
[clearHoverOutTimeout]
[clearHoverOutTimeout],
);
const handleTerminalCloseHoverIn = useCallback(
@@ -259,7 +232,7 @@ export function TerminalPane({
setHoveredTerminalId(terminalId);
setHoveredCloseTerminalId(terminalId);
},
[clearHoverOutTimeout]
[clearHoverOutTimeout],
);
const handleTerminalCloseHoverOut = useCallback((terminalId: string) => {
@@ -308,7 +281,7 @@ export function TerminalPane({
keyboardRefitTimeoutsRef.current = TERMINAL_REFIT_DELAYS_MS.map((delayMs) =>
setTimeout(() => {
requestTerminalReflow();
}, delayMs)
}, delayMs),
);
}, [clearKeyboardRefitTimeouts, requestTerminalReflow]);
@@ -324,7 +297,7 @@ export function TerminalPane({
}
runOnJS(pulseKeyboardRefits)();
},
[pulseKeyboardRefits]
[pulseKeyboardRefits],
);
useFocusEffect(
@@ -337,7 +310,7 @@ export function TerminalPane({
const timeoutHandles = TERMINAL_REFIT_DELAYS_MS.map((delayMs) =>
setTimeout(() => {
requestTerminalReflow();
}, delayMs)
}, delayMs),
);
return () => {
@@ -345,7 +318,7 @@ export function TerminalPane({
clearTimeout(handle);
}
};
}, [requestTerminalReflow, selectedTerminalId])
}, [requestTerminalReflow, selectedTerminalId]),
);
const terminalsQuery = useQuery({
@@ -386,12 +359,7 @@ export function TerminalPane({
}, [client, isConnected]);
useEffect(() => {
if (
!manageTerminalDirectorySubscription ||
!client ||
!isConnected ||
!cwd.startsWith("/")
) {
if (!manageTerminalDirectorySubscription || !client || !isConnected || !cwd.startsWith("/")) {
return;
}
@@ -494,21 +462,18 @@ export function TerminalPane({
streamControllerRef.current?.pruneResumeOffsets({ terminalIds });
}, [outputSession, terminals]);
const handleStreamControllerStatus = useCallback(
(status: TerminalStreamControllerStatus) => {
setIsAttaching(status.isAttaching);
setStreamError(status.error);
if (status.terminalId && typeof status.streamId === "number") {
setActiveStream({
terminalId: status.terminalId,
streamId: status.streamId,
});
return;
}
setActiveStream(null);
},
[]
);
const handleStreamControllerStatus = useCallback((status: TerminalStreamControllerStatus) => {
setIsAttaching(status.isAttaching);
setStreamError(status.error);
if (status.terminalId && typeof status.streamId === "number") {
setActiveStream({
terminalId: status.terminalId,
streamId: status.streamId,
});
return;
}
setActiveStream(null);
}, []);
useEffect(() => {
streamControllerRef.current?.dispose();
@@ -566,16 +531,14 @@ export function TerminalPane({
}, [isScreenFocused, outputSession, selectedTerminalId]);
const activeStreamId =
activeStream && activeStream.terminalId === selectedTerminalId
? activeStream.streamId
: null;
activeStream && activeStream.terminalId === selectedTerminalId ? activeStream.streamId : null;
const getCurrentActiveStreamId = useCallback(() => {
return streamControllerRef.current?.getActiveStreamId() ?? null;
}, []);
const selectedTerminal = useMemo(
() => terminals.find((terminal) => terminal.id === selectedTerminalId) ?? null,
[terminals, selectedTerminalId]
[terminals, selectedTerminalId],
);
const handleCreateTerminal = useCallback(() => {
createTerminalMutation.mutate();
@@ -618,7 +581,7 @@ export function TerminalPane({
});
return true;
},
[client]
[client],
);
const flushPendingTerminalInput = useCallback(() => {
@@ -650,10 +613,7 @@ export function TerminalPane({
const handleCloseTerminal = useCallback(
async (terminalId: string) => {
if (
killTerminalMutation.isPending &&
killTerminalMutation.variables === terminalId
) {
if (killTerminalMutation.isPending && killTerminalMutation.variables === terminalId) {
return;
}
@@ -671,7 +631,7 @@ export function TerminalPane({
killTerminalMutation.mutate(terminalId);
},
[killTerminalMutation]
[killTerminalMutation],
);
const clearPendingModifiers = useCallback(() => {
@@ -679,15 +639,13 @@ export function TerminalPane({
}, []);
const sendTerminalKey = useCallback(
(
input: {
key: string;
ctrl: boolean;
shift: boolean;
alt: boolean;
meta?: boolean;
}
): boolean => {
(input: {
key: string;
ctrl: boolean;
shift: boolean;
alt: boolean;
meta?: boolean;
}): boolean => {
if (!client || !selectedTerminalIdRef.current) {
enqueuePendingTerminalInput({
type: "key",
@@ -718,12 +676,7 @@ export function TerminalPane({
}
return true;
},
[
client,
dispatchTerminalInputEntry,
enqueuePendingTerminalInput,
getCurrentActiveStreamId,
]
[client, dispatchTerminalInputEntry, enqueuePendingTerminalInput, getCurrentActiveStreamId],
);
const handleTerminalData = useCallback(
@@ -782,7 +735,7 @@ export function TerminalPane({
modifiers.shift,
sendTerminalKey,
enqueuePendingTerminalInput,
]
],
);
const handleTerminalResize = useCallback(
@@ -794,11 +747,7 @@ export function TerminalPane({
const normalizedRows = Math.floor(rows);
const normalizedCols = Math.floor(cols);
const previous = lastReportedSizeRef.current;
if (
previous &&
previous.rows === normalizedRows &&
previous.cols === normalizedCols
) {
if (previous && previous.rows === normalizedRows && previous.cols === normalizedCols) {
return;
}
lastReportedSizeRef.current = { rows: normalizedRows, cols: normalizedCols };
@@ -808,36 +757,33 @@ export function TerminalPane({
cols: normalizedCols,
});
},
[client, selectedTerminalId]
[client, selectedTerminalId],
);
const handleTerminalKey = useCallback(
async (input: {
key: string;
ctrl: boolean;
shift: boolean;
alt: boolean;
meta: boolean;
}) => {
async (input: { key: string; ctrl: boolean; shift: boolean; alt: boolean; meta: boolean }) => {
sendTerminalKey(input);
},
[sendTerminalKey]
[sendTerminalKey],
);
const handlePendingModifiersConsumed = useCallback(() => {
clearPendingModifiers();
}, [clearPendingModifiers]);
const handleOutputChunkConsumed = useCallback((sequence: number) => {
outputSession.consume({ sequence });
}, [outputSession]);
const handleOutputChunkConsumed = useCallback(
(sequence: number) => {
outputSession.consume({ sequence });
},
[outputSession],
);
const toggleModifier = useCallback(
(modifier: keyof ModifierState) => {
setModifiers((current) => ({ ...current, [modifier]: !current[modifier] }));
requestTerminalFocus();
},
[requestTerminalFocus]
[requestTerminalFocus],
);
const sendVirtualKey = useCallback(
@@ -859,7 +805,7 @@ export function TerminalPane({
modifiers.shift,
requestTerminalFocus,
sendTerminalKey,
]
],
);
if (!client || !isConnected) {
@@ -870,17 +816,12 @@ export function TerminalPane({
);
}
const queryError =
terminalsQuery.error instanceof Error ? terminalsQuery.error.message : null;
const queryError = terminalsQuery.error instanceof Error ? terminalsQuery.error.message : null;
const isCreating = createTerminalMutation.isPending;
const createError =
createTerminalMutation.error instanceof Error
? createTerminalMutation.error.message
: null;
createTerminalMutation.error instanceof Error ? createTerminalMutation.error.message : null;
const closeError =
killTerminalMutation.error instanceof Error
? killTerminalMutation.error.message
: null;
killTerminalMutation.error instanceof Error ? killTerminalMutation.error.message : null;
const combinedError = streamError ?? closeError ?? createError ?? queryError;
return (
@@ -898,13 +839,11 @@ export function TerminalPane({
const isTabHovered = hoveredTerminalId === terminal.id;
const isCloseHovered = hoveredCloseTerminalId === terminal.id;
const isClosingTerminal =
killTerminalMutation.isPending &&
killTerminalMutation.variables === terminal.id;
const shouldShowCloseButton =
isTabHovered || isCloseHovered || isClosingTerminal;
killTerminalMutation.isPending && killTerminalMutation.variables === terminal.id;
const shouldShowCloseButton = isTabHovered || isCloseHovered || isClosingTerminal;
const gradientId = `terminal-close-gradient-${terminal.id.replace(
/[^a-zA-Z0-9_-]/g,
"-"
"-",
)}`;
return (
<Pressable
@@ -946,9 +885,7 @@ export function TerminalPane({
>
{({ hovered = false, pressed = false }) => {
const iconColor =
hovered || pressed
? theme.colors.foreground
: theme.colors.foregroundMuted;
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted;
return (
<>
<TerminalCloseGradient
@@ -1044,11 +981,7 @@ export function TerminalPane({
)}
{isAttaching && isScreenFocused ? (
<View
style={styles.attachOverlay}
pointerEvents="none"
testID="terminal-attach-loading"
>
<View style={styles.attachOverlay} pointerEvents="none" testID="terminal-attach-loading">
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
</View>
) : null}
@@ -1077,7 +1010,12 @@ export function TerminalPane({
(hovered || pressed) && styles.keyButtonHovered,
]}
>
<Text style={[styles.keyButtonText, modifiers[modifier] && styles.keyButtonTextActive]}>
<Text
style={[
styles.keyButtonText,
modifiers[modifier] && styles.keyButtonTextActive,
]}
>
{MODIFIER_LABELS[modifier]}
</Text>
</Pressable>
+37 -74
View File
@@ -1,20 +1,6 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import {
Animated,
Easing,
Platform,
Text,
ToastAndroid,
View,
} from "react-native";
import { Animated, Easing, Platform, Text, ToastAndroid, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { AlertTriangle, CheckCircle2 } from "lucide-react-native";
@@ -63,46 +49,33 @@ export function useToastHost(): {
const [toast, setToast] = useState<ToastState | null>(null);
const idRef = useRef(0);
const show = useCallback(
(content: ReactNode, options?: ToastShowOptions) => {
const nativeMessage =
typeof content === "string"
? content.trim()
: null;
if (!content || nativeMessage === "") {
return;
}
const show = useCallback((content: ReactNode, options?: ToastShowOptions) => {
const nativeMessage = typeof content === "string" ? content.trim() : null;
if (!content || nativeMessage === "") {
return;
}
const variant = options?.variant ?? "default";
const durationMs = options?.durationMs ?? DEFAULT_DURATION_MS;
const nativeAndroid = options?.nativeAndroid ?? false;
const variant = options?.variant ?? "default";
const durationMs = options?.durationMs ?? DEFAULT_DURATION_MS;
const nativeAndroid = options?.nativeAndroid ?? false;
if (Platform.OS === "android" && nativeAndroid && nativeMessage) {
const duration =
durationMs <= 2500
? ToastAndroid.SHORT
: ToastAndroid.LONG;
ToastAndroid.showWithGravity(
nativeMessage,
duration,
ToastAndroid.TOP
);
return;
}
if (Platform.OS === "android" && nativeAndroid && nativeMessage) {
const duration = durationMs <= 2500 ? ToastAndroid.SHORT : ToastAndroid.LONG;
ToastAndroid.showWithGravity(nativeMessage, duration, ToastAndroid.TOP);
return;
}
idRef.current += 1;
setToast({
id: idRef.current,
content,
nativeMessage,
icon: options?.icon,
variant,
durationMs,
testID: options?.testID,
});
},
[]
);
idRef.current += 1;
setToast({
id: idRef.current,
content,
nativeMessage,
icon: options?.icon,
variant,
durationMs,
testID: options?.testID,
});
}, []);
const api = useMemo<ToastApi>(
() => ({
@@ -112,10 +85,9 @@ export function useToastHost(): {
variant: "success",
icon: <CheckCircle2 size={18} />,
}),
error: (message: string) =>
show(message, { variant: "error", durationMs: 3200 }),
error: (message: string) => show(message, { variant: "error", durationMs: 3200 }),
}),
[show]
[show],
);
const dismiss = useCallback(() => {
@@ -209,8 +181,7 @@ export function ToastViewport({
return null;
}
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const headerHeight = isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT;
const headerTopPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
const topOffset =
@@ -219,13 +190,12 @@ export function ToastViewport({
: theme.spacing[3];
const icon =
toast.icon ?? (
toast.variant === "success" ? (
<CheckCircle2 size={18} color={theme.colors.primary} />
) : toast.variant === "error" ? (
<AlertTriangle size={18} color={theme.colors.destructive} />
) : null
);
toast.icon ??
(toast.variant === "success" ? (
<CheckCircle2 size={18} color={theme.colors.primary} />
) : toast.variant === "error" ? (
<AlertTriangle size={18} color={theme.colors.destructive} />
) : null);
const content = (
<View style={styles.container} pointerEvents="box-none">
@@ -247,10 +217,7 @@ export function ToastViewport({
{typeof toast.content === "string" ? (
<Text
testID="app-toast-message"
style={[
styles.message,
toast.variant === "error" ? styles.messageError : null,
]}
style={[styles.message, toast.variant === "error" ? styles.messageError : null]}
numberOfLines={2}
>
{toast.content}
@@ -264,11 +231,7 @@ export function ToastViewport({
</View>
);
if (
placement === "app-shell" &&
Platform.OS === "web" &&
typeof document !== "undefined"
) {
if (placement === "app-shell" && Platform.OS === "web" && typeof document !== "undefined") {
return createPortal(content, getOverlayRoot());
}
+161 -182
View File
@@ -4,10 +4,7 @@ import { ScrollView as GHScrollView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
import {
buildLineDiff,
parseUnifiedDiff,
} from "@/utils/tool-call-parsers";
import { buildLineDiff, parseUnifiedDiff } from "@/utils/tool-call-parsers";
import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state";
import { DiffViewer } from "./diff-viewer";
import { getCodeInsets } from "./code-insets";
@@ -60,10 +57,7 @@ export function ToolCallDetailsContent({
const commandOutput = (detail.output ?? "").replace(/^\n+/, "");
const hasOutput = commandOutput.length > 0;
sections.push(
<View
key="shell"
style={[styles.section, shouldFill && styles.fillHeight]}
>
<View key="shell" style={[styles.section, shouldFill && styles.fillHeight]}>
<View style={[codeBlockStyle, shouldFill && styles.fillHeight]}>
<ScrollView
style={[
@@ -91,16 +85,13 @@ export function ToolCallDetailsContent({
</ScrollView>
</ScrollView>
</View>
</View>
</View>,
);
} else if (detail?.type === "worktree_setup") {
const setupLog = detail.log.replace(/^\n+/, "");
const hasLog = setupLog.length > 0;
sections.push(
<View
key="worktree-setup"
style={[styles.section, shouldFill && styles.fillHeight]}
>
<View key="worktree-setup" style={[styles.section, shouldFill && styles.fillHeight]}>
<View style={[codeBlockStyle, shouldFill && styles.fillHeight]}>
<ScrollView
style={[
@@ -128,7 +119,7 @@ export function ToolCallDetailsContent({
</ScrollView>
</ScrollView>
</View>
</View>
</View>,
);
} else if (detail?.type === "sub_agent") {
const activityLog = detail.log.replace(/^\n+/, "");
@@ -136,12 +127,9 @@ export function ToolCallDetailsContent({
const fallbackHeader =
detail.subAgentType && detail.description
? `${detail.subAgentType}: ${detail.description}`
: detail.subAgentType ?? detail.description ?? "Sub-agent activity";
: (detail.subAgentType ?? detail.description ?? "Sub-agent activity");
sections.push(
<View
key="sub-agent"
style={[styles.section, shouldFill && styles.fillHeight]}
>
<View key="sub-agent" style={[styles.section, shouldFill && styles.fillHeight]}>
<View style={[codeBlockStyle, shouldFill && styles.fillHeight]}>
<ScrollView
style={[
@@ -167,14 +155,11 @@ export function ToolCallDetailsContent({
</ScrollView>
</ScrollView>
</View>
</View>
</View>,
);
} else if (detail?.type === "edit") {
sections.push(
<View
key="edit"
style={[styles.section, shouldFill && styles.fillHeight]}
>
<View key="edit" style={[styles.section, shouldFill && styles.fillHeight]}>
{diffLines ? (
<View style={[codeBlockStyle, shouldFill && styles.fillHeight]}>
<DiffViewer
@@ -184,14 +169,11 @@ export function ToolCallDetailsContent({
/>
</View>
) : null}
</View>
</View>,
);
} else if (detail?.type === "write") {
sections.push(
<View
key="write"
style={[styles.section, shouldFill && styles.fillHeight]}
>
<View key="write" style={[styles.section, shouldFill && styles.fillHeight]}>
{detail.content ? (
<ScrollView
style={[
@@ -203,24 +185,19 @@ export function ToolCallDetailsContent({
nestedScrollEnabled
showsVerticalScrollIndicator={true}
>
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator={true}
>
<Text selectable style={styles.scrollText}>{detail.content}</Text>
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator={true}>
<Text selectable style={styles.scrollText}>
{detail.content}
</Text>
</ScrollView>
</ScrollView>
) : null}
</View>
</View>,
);
} else if (detail?.type === "read") {
if (detail.content) {
sections.push(
<View
key="read"
style={[styles.section, shouldFill && styles.fillHeight]}
>
<View key="read" style={[styles.section, shouldFill && styles.fillHeight]}>
<ScrollView
style={[
styles.scrollArea,
@@ -231,15 +208,13 @@ export function ToolCallDetailsContent({
nestedScrollEnabled
showsVerticalScrollIndicator={true}
>
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator={true}
>
<Text selectable style={styles.scrollText}>{detail.content}</Text>
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator={true}>
<Text selectable style={styles.scrollText}>
{detail.content}
</Text>
</ScrollView>
</ScrollView>
</View>
</View>,
);
}
} else if (detail?.type === "search") {
@@ -247,8 +222,10 @@ export function ToolCallDetailsContent({
if (detail.query) {
searchSections.push(
<View key="search-query" style={styles.section}>
<Text selectable style={styles.scrollText}>{detail.query}</Text>
</View>
<Text selectable style={styles.scrollText}>
{detail.query}
</Text>
</View>,
);
}
if (detail.content) {
@@ -264,17 +241,21 @@ export function ToolCallDetailsContent({
showsVerticalScrollIndicator
>
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
<Text selectable style={styles.scrollText}>{detail.content}</Text>
<Text selectable style={styles.scrollText}>
{detail.content}
</Text>
</ScrollView>
</ScrollView>
</View>
</View>,
);
}
if (detail.filePaths && detail.filePaths.length > 0) {
searchSections.push(
<View key="search-files" style={styles.section}>
<Text selectable style={styles.scrollText}>{detail.filePaths.join("\n")}</Text>
</View>
<Text selectable style={styles.scrollText}>
{detail.filePaths.join("\n")}
</Text>
</View>,
);
}
if (detail.webResults && detail.webResults.length > 0) {
@@ -283,23 +264,22 @@ export function ToolCallDetailsContent({
<Text selectable style={styles.scrollText}>
{detail.webResults.map((entry) => `${entry.title}\n${entry.url}`).join("\n\n")}
</Text>
</View>
</View>,
);
}
if (detail.annotations && detail.annotations.length > 0) {
searchSections.push(
<View key="search-annotations" style={styles.section}>
<Text selectable style={styles.scrollText}>{detail.annotations.join("\n\n")}</Text>
</View>
<Text selectable style={styles.scrollText}>
{detail.annotations.join("\n\n")}
</Text>
</View>,
);
}
sections.push(...searchSections);
} else if (detail?.type === "fetch") {
sections.push(
<View
key="fetch"
style={[styles.section, shouldFill && styles.fillHeight]}
>
<View key="fetch" style={[styles.section, shouldFill && styles.fillHeight]}>
<ScrollView
style={[
styles.scrollArea,
@@ -316,37 +296,41 @@ export function ToolCallDetailsContent({
</Text>
</ScrollView>
</ScrollView>
</View>
</View>,
);
} else if (detail?.type === "plain_text") {
if (detail.text) {
sections.push(
<View key="plain-text" style={styles.plainTextSection}>
<Text selectable style={styles.plainText}>{detail.text}</Text>
</View>
<Text selectable style={styles.plainText}>
{detail.text}
</Text>
</View>,
);
}
} else if (detail?.type === "unknown") {
const plainInputText =
typeof detail.input === "string" && detail.output === null
? detail.input
: null;
typeof detail.input === "string" && detail.output === null ? detail.input : null;
if (plainInputText !== null) {
sections.push(
<View key="unknown-plain-text" style={styles.plainTextSection}>
<Text selectable style={styles.plainText}>{plainInputText}</Text>
</View>
<Text selectable style={styles.plainText}>
{plainInputText}
</Text>
</View>,
);
} else {
const sectionsFromTopLevel = [
{ title: "Input", value: detail.input },
{ title: "Output", value: detail.output },
].filter((entry) => hasMeaningfulToolCallDetail({
type: "unknown",
input: entry.value ?? null,
output: null,
}));
].filter((entry) =>
hasMeaningfulToolCallDetail({
type: "unknown",
input: entry.value ?? null,
output: null,
}),
);
for (const section of sectionsFromTopLevel) {
let value = "";
@@ -364,7 +348,7 @@ export function ToolCallDetailsContent({
sections.push(
<View key={`${section.title}-header`} style={styles.groupHeader}>
<Text style={styles.groupHeaderText}>{section.title}</Text>
</View>
</View>,
);
sections.push(
<View key={`${section.title}-value`} style={styles.section}>
@@ -375,9 +359,11 @@ export function ToolCallDetailsContent({
contentContainerStyle={styles.jsonContent}
showsHorizontalScrollIndicator={true}
>
<Text selectable style={styles.scrollText}>{value}</Text>
<Text selectable style={styles.scrollText}>
{value}
</Text>
</ScrollView>
</View>
</View>,
);
}
}
@@ -399,28 +385,21 @@ export function ToolCallDetailsContent({
{errorText}
</Text>
</ScrollView>
</View>
</View>,
);
}
if (sections.length === 0) {
if (showLoadingSkeleton) {
return (
<View
style={[
styles.loadingContainer,
fillAvailableHeight && styles.fillHeight,
]}
>
<View style={[styles.loadingContainer, fillAvailableHeight && styles.fillHeight]}>
<View style={styles.loadingLineWide} />
<View style={styles.loadingLineMedium} />
<View style={styles.loadingLineShort} />
</View>
);
}
return (
<Text style={styles.emptyStateText}>No additional details available</Text>
);
return <Text style={styles.emptyStateText}>No additional details available</Text>;
}
return (
@@ -449,20 +428,20 @@ const styles = StyleSheet.create((theme) => {
gap: theme.spacing[2],
padding: 0,
},
groupHeader: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
},
groupHeaderText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
groupHeader: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
},
groupHeaderText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
section: {
gap: theme.spacing[2],
},
@@ -470,28 +449,28 @@ const styles = StyleSheet.create((theme) => {
flex: 1,
minHeight: 0,
},
plainTextSection: {
gap: theme.spacing[2],
padding: theme.spacing[3],
},
plainText: {
fontFamily: Fonts.sans,
fontSize: theme.fontSize.base,
color: theme.colors.foreground,
lineHeight: 22,
overflowWrap: "anywhere",
},
sectionTitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
textTransform: "uppercase",
letterSpacing: 0.5,
},
rangeText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
plainTextSection: {
gap: theme.spacing[2],
padding: theme.spacing[3],
},
plainText: {
fontFamily: Fonts.sans,
fontSize: theme.fontSize.base,
color: theme.colors.foreground,
lineHeight: 22,
overflowWrap: "anywhere",
},
sectionTitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
textTransform: "uppercase",
letterSpacing: 0.5,
},
rangeText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
diffContainer: {
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
@@ -505,28 +484,28 @@ const styles = StyleSheet.create((theme) => {
overflow: "hidden",
backgroundColor: theme.colors.surface1,
},
codeVerticalScroll: {},
codeVerticalContent: {
flexGrow: 1,
paddingBottom: insets.extraBottom,
},
codeHorizontalContent: {
paddingRight: insets.extraRight,
},
codeLine: {
minWidth: "100%",
paddingHorizontal: insets.padding,
paddingVertical: insets.padding,
},
scrollArea: {
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.base,
backgroundColor: theme.colors.surface2,
},
scrollContent: {
padding: insets.padding,
},
codeVerticalScroll: {},
codeVerticalContent: {
flexGrow: 1,
paddingBottom: insets.extraBottom,
},
codeHorizontalContent: {
paddingRight: insets.extraRight,
},
codeLine: {
minWidth: "100%",
paddingHorizontal: insets.padding,
paddingVertical: insets.padding,
},
scrollArea: {
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.base,
backgroundColor: theme.colors.surface2,
},
scrollContent: {
padding: insets.padding,
},
scrollText: {
fontFamily: Fonts.mono,
fontSize: theme.fontSize.xs,
@@ -546,43 +525,43 @@ const styles = StyleSheet.create((theme) => {
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.base,
backgroundColor: theme.colors.surface2,
},
jsonScrollError: {
borderColor: theme.colors.destructive,
},
jsonContent: {
padding: insets.padding,
},
errorText: {
color: theme.colors.destructive,
},
emptyStateText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontStyle: "italic",
},
loadingContainer: {
gap: theme.spacing[2],
padding: theme.spacing[3],
},
loadingLineWide: {
height: 12,
width: "100%",
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface3,
},
loadingLineMedium: {
height: 12,
width: "72%",
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface3,
},
loadingLineShort: {
height: 12,
width: "48%",
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface3,
},
backgroundColor: theme.colors.surface2,
},
jsonScrollError: {
borderColor: theme.colors.destructive,
},
jsonContent: {
padding: insets.padding,
},
errorText: {
color: theme.colors.destructive,
},
emptyStateText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontStyle: "italic",
},
loadingContainer: {
gap: theme.spacing[2],
padding: theme.spacing[3],
},
loadingLineWide: {
height: 12,
width: "100%",
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface3,
},
loadingLineMedium: {
height: 12,
width: "72%",
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface3,
},
loadingLineShort: {
height: 12,
width: "48%",
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface3,
},
};
});
@@ -1,11 +1,4 @@
import React, {
createContext,
useContext,
useCallback,
useMemo,
useRef,
ReactNode,
} from "react";
import React, { createContext, useContext, useCallback, useMemo, useRef, ReactNode } from "react";
import { View, Text, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import Animated from "react-native-reanimated";
@@ -54,7 +47,7 @@ function CustomSheetBackground({ style }: BottomSheetBackgroundProps) {
const { theme } = useUnistyles();
const containerStyle = useMemo(
() => [style, { backgroundColor: theme.colors.surface2, borderRadius: 16 }],
[style, theme.colors.surface2]
[style, theme.colors.surface2],
);
return <Animated.View pointerEvents="none" style={containerStyle} />;
}
@@ -88,19 +81,14 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps)
const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
opacity={0.5}
/>
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.5} />
),
[]
[],
);
const contextValue = useMemo(
() => ({ openToolCall, closeToolCall }),
[openToolCall, closeToolCall]
[openToolCall, closeToolCall],
);
return (
@@ -152,10 +140,7 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
</View>
{/* Content */}
<BottomSheetScrollView
style={styles.content}
contentContainerStyle={styles.contentContainer}
>
<BottomSheetScrollView style={styles.content} contentContainerStyle={styles.contentContainer}>
<ToolCallDetailsContent
detail={detail}
errorText={errorText}
@@ -10,19 +10,11 @@ const OPTIONS = ["alpha", "beta", "gamma"];
describe("orderAutocompleteOptions", () => {
it("keeps first logical option closest to the input by default", () => {
expect(orderAutocompleteOptions(OPTIONS)).toEqual([
"gamma",
"beta",
"alpha",
]);
expect(orderAutocompleteOptions(OPTIONS)).toEqual(["gamma", "beta", "alpha"]);
});
it("keeps normal top-down order when below-input is selected", () => {
expect(orderAutocompleteOptions(OPTIONS, "below-input")).toEqual([
"alpha",
"beta",
"gamma",
]);
expect(orderAutocompleteOptions(OPTIONS, "below-input")).toEqual(["alpha", "beta", "gamma"]);
});
});
@@ -45,7 +37,7 @@ describe("getAutocompleteScrollOffset", () => {
viewportHeight: 80,
itemTop: 90,
itemHeight: 20,
})
}),
).toBe(90);
});
@@ -56,7 +48,7 @@ describe("getAutocompleteScrollOffset", () => {
viewportHeight: 100,
itemTop: 150,
itemHeight: 24,
})
}),
).toBe(74);
});
});
@@ -4,7 +4,7 @@ export type AutocompleteOptionsPosition = "above-input" | "below-input";
export function orderAutocompleteOptions<T>(
options: readonly T[],
position: AutocompleteOptionsPosition = "above-input"
position: AutocompleteOptionsPosition = "above-input",
): T[] {
if (position === "below-input") {
return [...options];
@@ -14,7 +14,7 @@ export function orderAutocompleteOptions<T>(
export function getAutocompleteFallbackIndex(
itemCount: number,
position: AutocompleteOptionsPosition = "above-input"
position: AutocompleteOptionsPosition = "above-input",
): number {
if (itemCount <= 0) {
return -1;
+80 -80
View File
@@ -1,37 +1,37 @@
import { useCallback, useEffect, useRef } from 'react'
import { ScrollView, Text, View, Pressable, type LayoutChangeEvent } from 'react-native'
import { StyleSheet, useUnistyles } from 'react-native-unistyles'
import { File, Folder } from 'lucide-react-native'
import { Theme } from '@/styles/theme'
import { getAutocompleteScrollOffset } from './autocomplete-utils'
import { useCallback, useEffect, useRef } from "react";
import { ScrollView, Text, View, Pressable, type LayoutChangeEvent } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { File, Folder } from "lucide-react-native";
import { Theme } from "@/styles/theme";
import { getAutocompleteScrollOffset } from "./autocomplete-utils";
export interface AutocompleteOption {
id: string
label: string
detail?: string
description?: string
kind?: 'command' | 'file' | 'directory'
id: string;
label: string;
detail?: string;
description?: string;
kind?: "command" | "file" | "directory";
}
interface AutocompleteProps {
options: readonly AutocompleteOption[]
selectedIndex: number
onSelect: (option: AutocompleteOption) => void
isLoading?: boolean
errorMessage?: string
loadingText?: string
emptyText?: string
maxHeight?: number
options: readonly AutocompleteOption[];
selectedIndex: number;
onSelect: (option: AutocompleteOption) => void;
isLoading?: boolean;
errorMessage?: string;
loadingText?: string;
emptyText?: string;
maxHeight?: number;
}
const BOLT_GLYPH_PATTERN = /[\u26A1\uFE0F]/g
const BOLT_GLYPH_PATTERN = /[\u26A1\uFE0F]/g;
function removeBoltGlyphs(value?: string): string | undefined {
if (!value) {
return value
return value;
}
const cleaned = value.replace(BOLT_GLYPH_PATTERN, '').trim()
return cleaned.length > 0 ? cleaned : undefined
const cleaned = value.replace(BOLT_GLYPH_PATTERN, "").trim();
return cleaned.length > 0 ? cleaned : undefined;
}
export function Autocomplete({
@@ -40,24 +40,24 @@ export function Autocomplete({
onSelect,
isLoading = false,
errorMessage,
loadingText = 'Loading...',
emptyText = 'No results found',
loadingText = "Loading...",
emptyText = "No results found",
maxHeight = 220,
}: AutocompleteProps) {
const { theme } = useUnistyles()
const scrollRef = useRef<ScrollView>(null)
const rowLayoutsRef = useRef<Map<number, { top: number; height: number }>>(new Map())
const viewportHeightRef = useRef(0)
const scrollOffsetRef = useRef(0)
const { theme } = useUnistyles();
const scrollRef = useRef<ScrollView>(null);
const rowLayoutsRef = useRef<Map<number, { top: number; height: number }>>(new Map());
const viewportHeightRef = useRef(0);
const scrollOffsetRef = useRef(0);
const ensureActiveItemVisible = useCallback(() => {
if (selectedIndex < 0) {
return
return;
}
const layout = rowLayoutsRef.current.get(selectedIndex)
const layout = rowLayoutsRef.current.get(selectedIndex);
if (!layout) {
return
return;
}
const nextOffset = getAutocompleteScrollOffset({
@@ -65,60 +65,60 @@ export function Autocomplete({
viewportHeight: viewportHeightRef.current,
itemTop: layout.top,
itemHeight: layout.height,
})
});
if (Math.abs(nextOffset - scrollOffsetRef.current) < 1) {
return
return;
}
scrollOffsetRef.current = nextOffset
scrollRef.current?.scrollTo({ y: nextOffset, animated: false })
}, [selectedIndex])
scrollOffsetRef.current = nextOffset;
scrollRef.current?.scrollTo({ y: nextOffset, animated: false });
}, [selectedIndex]);
const pinToBottom = useCallback(() => {
scrollRef.current?.scrollToEnd({ animated: false })
scrollRef.current?.scrollToEnd({ animated: false });
requestAnimationFrame(() => {
scrollRef.current?.scrollToEnd({ animated: false })
})
}, [])
scrollRef.current?.scrollToEnd({ animated: false });
});
}, []);
useEffect(() => {
rowLayoutsRef.current.clear()
scrollOffsetRef.current = 0
}, [options])
rowLayoutsRef.current.clear();
scrollOffsetRef.current = 0;
}, [options]);
useEffect(() => {
if (options.length === 0) {
return
return;
}
pinToBottom()
}, [options, pinToBottom])
pinToBottom();
}, [options, pinToBottom]);
useEffect(() => {
const raf = requestAnimationFrame(ensureActiveItemVisible)
const raf = requestAnimationFrame(ensureActiveItemVisible);
return () => {
cancelAnimationFrame(raf)
}
}, [ensureActiveItemVisible, options.length])
cancelAnimationFrame(raf);
};
}, [ensureActiveItemVisible, options.length]);
const handleScrollViewLayout = useCallback(
(event: LayoutChangeEvent) => {
viewportHeightRef.current = event.nativeEvent.layout.height
ensureActiveItemVisible()
viewportHeightRef.current = event.nativeEvent.layout.height;
ensureActiveItemVisible();
},
[ensureActiveItemVisible]
)
[ensureActiveItemVisible],
);
const handleRowLayout = useCallback(
(index: number, event: LayoutChangeEvent) => {
rowLayoutsRef.current.set(index, {
top: event.nativeEvent.layout.y,
height: event.nativeEvent.layout.height,
})
ensureActiveItemVisible()
});
ensureActiveItemVisible();
},
[ensureActiveItemVisible]
)
[ensureActiveItemVisible],
);
if (isLoading) {
return (
@@ -127,7 +127,7 @@ export function Autocomplete({
<Text style={styles.emptyText}>{loadingText}</Text>
</View>
</View>
)
);
}
if (errorMessage) {
@@ -137,7 +137,7 @@ export function Autocomplete({
<Text style={styles.emptyText}>Error: {errorMessage}</Text>
</View>
</View>
)
);
}
if (options.length === 0) {
@@ -147,7 +147,7 @@ export function Autocomplete({
<Text style={styles.emptyText}>{emptyText}</Text>
</View>
</View>
)
);
}
return (
@@ -157,7 +157,7 @@ export function Autocomplete({
onLayout={handleScrollViewLayout}
onContentSizeChange={pinToBottom}
onScroll={(event) => {
scrollOffsetRef.current = event.nativeEvent.contentOffset.y
scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
}}
scrollEventThrottle={16}
style={styles.scrollView}
@@ -165,10 +165,10 @@ export function Autocomplete({
keyboardShouldPersistTaps="always"
>
{options.map((option, index) => {
const isSelected = index === selectedIndex
const optionLabel = removeBoltGlyphs(option.label) ?? option.label
const optionDetail = removeBoltGlyphs(option.detail)
const optionDescription = removeBoltGlyphs(option.description)
const isSelected = index === selectedIndex;
const optionLabel = removeBoltGlyphs(option.label) ?? option.label;
const optionDetail = removeBoltGlyphs(option.detail);
const optionDescription = removeBoltGlyphs(option.description);
return (
<Pressable
key={option.id}
@@ -179,9 +179,9 @@ export function Autocomplete({
(hovered || pressed || isSelected) && styles.itemActive,
]}
>
{option.kind === 'directory' || option.kind === 'file' ? (
{option.kind === "directory" || option.kind === "file" ? (
<View style={styles.itemLeading}>
{option.kind === 'directory' ? (
{option.kind === "directory" ? (
<Folder size={14} color={theme.colors.foregroundMuted} />
) : (
<File size={14} color={theme.colors.foregroundMuted} />
@@ -200,11 +200,11 @@ export function Autocomplete({
) : null}
</View>
</Pressable>
)
);
})}
</ScrollView>
</View>
)
);
}
const styles = StyleSheet.create(((theme: Theme) => ({
@@ -213,7 +213,7 @@ const styles = StyleSheet.create(((theme: Theme) => ({
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
overflow: 'hidden',
overflow: "hidden",
},
scrollView: {
flexGrow: 0,
@@ -223,16 +223,16 @@ const styles = StyleSheet.create(((theme: Theme) => ({
paddingVertical: theme.spacing[1],
},
item: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
minHeight: 36,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
itemLeading: {
width: 18,
alignItems: 'center',
justifyContent: 'center',
alignItems: "center",
justifyContent: "center",
marginRight: theme.spacing[1],
},
itemActive: {
@@ -243,8 +243,8 @@ const styles = StyleSheet.create(((theme: Theme) => ({
minWidth: 0,
},
itemHeader: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
itemLabel: {
@@ -269,4 +269,4 @@ const styles = StyleSheet.create(((theme: Theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
})) as any) as Record<string, any>
})) as any) as Record<string, any>;
@@ -4,30 +4,19 @@ import { getNextActiveIndex } from "./combobox-keyboard";
describe("getNextActiveIndex", () => {
it("returns -1 when itemCount is 0", () => {
expect(
getNextActiveIndex({ currentIndex: 0, itemCount: 0, key: "ArrowDown" })
).toBe(-1);
expect(getNextActiveIndex({ currentIndex: 0, itemCount: 0, key: "ArrowDown" })).toBe(-1);
});
it("starts at 0 on ArrowDown when no active item", () => {
expect(
getNextActiveIndex({ currentIndex: -1, itemCount: 3, key: "ArrowDown" })
).toBe(0);
expect(getNextActiveIndex({ currentIndex: -1, itemCount: 3, key: "ArrowDown" })).toBe(0);
});
it("starts at last on ArrowUp when no active item", () => {
expect(
getNextActiveIndex({ currentIndex: -1, itemCount: 3, key: "ArrowUp" })
).toBe(2);
expect(getNextActiveIndex({ currentIndex: -1, itemCount: 3, key: "ArrowUp" })).toBe(2);
});
it("wraps around on ArrowDown and ArrowUp", () => {
expect(
getNextActiveIndex({ currentIndex: 2, itemCount: 3, key: "ArrowDown" })
).toBe(0);
expect(
getNextActiveIndex({ currentIndex: 0, itemCount: 3, key: "ArrowUp" })
).toBe(2);
expect(getNextActiveIndex({ currentIndex: 2, itemCount: 3, key: "ArrowDown" })).toBe(0);
expect(getNextActiveIndex({ currentIndex: 0, itemCount: 3, key: "ArrowUp" })).toBe(2);
});
});
@@ -16,4 +16,3 @@ export function getNextActiveIndex(args: {
? (normalizedCurrent + 1) % itemCount
: (normalizedCurrent - 1 + itemCount) % itemCount;
}
@@ -1,95 +1,97 @@
export type ComboboxOptionKind = 'directory' | 'file'
export type ComboboxOptionKind = "directory" | "file";
export interface ComboboxOptionModel {
id: string
label: string
description?: string
kind?: ComboboxOptionKind
id: string;
label: string;
description?: string;
kind?: ComboboxOptionKind;
}
export interface BuildVisibleComboboxOptionsInput {
options: ComboboxOptionModel[]
searchQuery: string
searchable: boolean
allowCustomValue: boolean
customValuePrefix: string
customValueDescription?: string
customValueKind?: ComboboxOptionKind
options: ComboboxOptionModel[];
searchQuery: string;
searchable: boolean;
allowCustomValue: boolean;
customValuePrefix: string;
customValueDescription?: string;
customValueKind?: ComboboxOptionKind;
}
export function shouldShowCustomComboboxOption(input: {
options: ComboboxOptionModel[]
searchQuery: string
searchable: boolean
allowCustomValue: boolean
options: ComboboxOptionModel[];
searchQuery: string;
searchable: boolean;
allowCustomValue: boolean;
}): boolean {
const sanitizedSearchValue = input.searchQuery.trim()
const sanitizedSearchValue = input.searchQuery.trim();
if (!input.searchable || !input.allowCustomValue || sanitizedSearchValue.length === 0) {
return false
return false;
}
return !input.options.some(
(opt) =>
opt.id.toLowerCase() === sanitizedSearchValue.toLowerCase() ||
opt.label.toLowerCase() === sanitizedSearchValue.toLowerCase()
)
opt.label.toLowerCase() === sanitizedSearchValue.toLowerCase(),
);
}
export function buildVisibleComboboxOptions(
input: BuildVisibleComboboxOptionsInput
input: BuildVisibleComboboxOptionsInput,
): ComboboxOptionModel[] {
const normalizedSearch = input.searchable ? input.searchQuery.trim().toLowerCase() : ''
const normalizedSearch = input.searchable ? input.searchQuery.trim().toLowerCase() : "";
const filteredOptions = normalizedSearch
? input.options.filter(
(opt) =>
opt.label.toLowerCase().includes(normalizedSearch) ||
opt.id.toLowerCase().includes(normalizedSearch) ||
opt.description?.toLowerCase().includes(normalizedSearch)
opt.description?.toLowerCase().includes(normalizedSearch),
)
: input.options
: input.options;
const sanitizedSearchValue = input.searchQuery.trim()
const sanitizedSearchValue = input.searchQuery.trim();
const showCustomOption = shouldShowCustomComboboxOption({
options: input.options,
searchQuery: input.searchQuery,
searchable: input.searchable,
allowCustomValue: input.allowCustomValue,
})
});
const visibleOptions: ComboboxOptionModel[] = []
const visibleOptions: ComboboxOptionModel[] = [];
if (showCustomOption) {
const trimmedPrefix = input.customValuePrefix.trim()
const trimmedPrefix = input.customValuePrefix.trim();
const customLabel =
trimmedPrefix.length > 0 ? `${trimmedPrefix} "${sanitizedSearchValue}"` : sanitizedSearchValue
trimmedPrefix.length > 0
? `${trimmedPrefix} "${sanitizedSearchValue}"`
: sanitizedSearchValue;
visibleOptions.push({
id: sanitizedSearchValue,
label: customLabel,
description: input.customValueDescription,
kind: input.customValueKind,
})
});
}
visibleOptions.push(...filteredOptions)
return visibleOptions
visibleOptions.push(...filteredOptions);
return visibleOptions;
}
export function orderVisibleComboboxOptions(
visibleOptions: ComboboxOptionModel[],
optionsPosition: 'below-search' | 'above-search'
optionsPosition: "below-search" | "above-search",
): ComboboxOptionModel[] {
if (optionsPosition !== 'above-search') {
return visibleOptions
if (optionsPosition !== "above-search") {
return visibleOptions;
}
return [...visibleOptions].reverse()
return [...visibleOptions].reverse();
}
export function getComboboxFallbackIndex(
itemCount: number,
optionsPosition: 'below-search' | 'above-search'
optionsPosition: "below-search" | "above-search",
): number {
if (itemCount <= 0) {
return -1
return -1;
}
return optionsPosition === 'above-search' ? itemCount - 1 : 0
return optionsPosition === "above-search" ? itemCount - 1 : 0;
}
+273 -273
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { ReactElement, ReactNode } from 'react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { ReactElement, ReactNode } from "react";
import {
View,
Text,
@@ -10,95 +10,95 @@ import {
Platform,
StatusBar,
useWindowDimensions,
} from 'react-native'
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
} from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import {
BottomSheetModal,
BottomSheetScrollView,
BottomSheetBackdrop,
BottomSheetTextInput,
BottomSheetBackgroundProps,
} from '@gorhom/bottom-sheet'
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'
import { Check, File, Folder, Search } from 'lucide-react-native'
} from "@gorhom/bottom-sheet";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { Check, File, Folder, Search } from "lucide-react-native";
import {
flip,
offset as floatingOffset,
shift,
size as floatingSize,
useFloating,
} from '@floating-ui/react-native'
import { getNextActiveIndex } from './combobox-keyboard'
} from "@floating-ui/react-native";
import { getNextActiveIndex } from "./combobox-keyboard";
import {
buildVisibleComboboxOptions,
getComboboxFallbackIndex,
orderVisibleComboboxOptions,
shouldShowCustomComboboxOption,
} from './combobox-options'
import type { ComboboxOptionModel } from './combobox-options'
} from "./combobox-options";
import type { ComboboxOptionModel } from "./combobox-options";
const IS_WEB = Platform.OS === 'web'
const IS_WEB = Platform.OS === "web";
export type ComboboxOption = ComboboxOptionModel
export type ComboboxOption = ComboboxOptionModel;
export interface ComboboxProps {
options: ComboboxOption[]
value: string
onSelect: (id: string) => void
options: ComboboxOption[];
value: string;
onSelect: (id: string) => void;
renderOption?: (input: {
option: ComboboxOption
selected: boolean
active: boolean
onPress: () => void
}) => ReactElement
onSearchQueryChange?: (query: string) => void
searchable?: boolean
placeholder?: string
searchPlaceholder?: string
emptyText?: string
allowCustomValue?: boolean
customValuePrefix?: string
customValueDescription?: string
customValueKind?: 'directory' | 'file'
optionsPosition?: 'below-search' | 'above-search'
title?: string
open?: boolean
onOpenChange?: (open: boolean) => void
enableDismissOnClose?: boolean
desktopPlacement?: 'top-start' | 'bottom-start'
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}) => ReactElement;
onSearchQueryChange?: (query: string) => void;
searchable?: boolean;
placeholder?: string;
searchPlaceholder?: string;
emptyText?: string;
allowCustomValue?: boolean;
customValuePrefix?: string;
customValueDescription?: string;
customValueKind?: "directory" | "file";
optionsPosition?: "below-search" | "above-search";
title?: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
enableDismissOnClose?: boolean;
desktopPlacement?: "top-start" | "bottom-start";
/**
* Prevents an initial frame at 0,0 by hiding desktop content until floating
* coordinates resolve. This intentionally disables fade enter/exit animation
* for that combobox instance to avoid animation overriding hidden opacity.
*/
desktopPreventInitialFlash?: boolean
anchorRef: React.RefObject<View | null>
children?: ReactNode
desktopPreventInitialFlash?: boolean;
anchorRef: React.RefObject<View | null>;
children?: ReactNode;
}
function toNumericStyleValue(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number.parseFloat(value)
if (typeof value === "string") {
const parsed = Number.parseFloat(value);
if (Number.isFinite(parsed)) {
return parsed
return parsed;
}
}
return null
return null;
}
function ComboboxSheetBackground({ style }: BottomSheetBackgroundProps) {
return <Animated.View pointerEvents="none" style={[style, styles.bottomSheetBackground]} />
return <Animated.View pointerEvents="none" style={[style, styles.bottomSheetBackground]} />;
}
export interface SearchInputProps {
placeholder: string
value: string
onChangeText: (text: string) => void
onSubmitEditing?: () => void
autoFocus?: boolean
placeholder: string;
value: string;
onChangeText: (text: string) => void;
onSubmitEditing?: () => void;
autoFocus?: boolean;
}
export function SearchInput({
@@ -108,18 +108,18 @@ export function SearchInput({
onSubmitEditing,
autoFocus = false,
}: SearchInputProps): ReactElement {
const { theme } = useUnistyles()
const inputRef = useRef<TextInput>(null)
const InputComponent = Platform.OS === 'web' ? TextInput : BottomSheetTextInput
const { theme } = useUnistyles();
const inputRef = useRef<TextInput>(null);
const InputComponent = Platform.OS === "web" ? TextInput : BottomSheetTextInput;
useEffect(() => {
if (autoFocus && IS_WEB && inputRef.current) {
const timer = setTimeout(() => {
inputRef.current?.focus()
}, 50)
return () => clearTimeout(timer)
inputRef.current?.focus();
}, 50);
return () => clearTimeout(timer);
}
}, [autoFocus])
}, [autoFocus]);
return (
<View style={styles.searchInputContainer}>
@@ -127,7 +127,7 @@ export function SearchInput({
<InputComponent
ref={inputRef as any}
// @ts-expect-error - outlineStyle is web-only
style={[styles.searchInput, IS_WEB && { outlineStyle: 'none' }]}
style={[styles.searchInput, IS_WEB && { outlineStyle: "none" }]}
placeholder={placeholder}
placeholderTextColor={theme.colors.foregroundMuted}
value={value}
@@ -137,18 +137,18 @@ export function SearchInput({
onSubmitEditing={onSubmitEditing}
/>
</View>
)
);
}
export interface ComboboxItemProps {
label: string
description?: string
kind?: 'directory' | 'file'
leadingSlot?: ReactNode
selected?: boolean
active?: boolean
onPress: () => void
testID?: string
label: string;
description?: string;
kind?: "directory" | "file";
leadingSlot?: ReactNode;
selected?: boolean;
active?: boolean;
onPress: () => void;
testID?: string;
}
export function ComboboxItem({
@@ -161,19 +161,19 @@ export function ComboboxItem({
onPress,
testID,
}: ComboboxItemProps): ReactElement {
const { theme } = useUnistyles()
const { theme } = useUnistyles();
const leadingContent = leadingSlot ? (
<View style={styles.comboboxItemLeadingSlot}>{leadingSlot}</View>
) : kind === 'directory' || kind === 'file' ? (
) : kind === "directory" || kind === "file" ? (
<View style={styles.comboboxItemLeadingSlot}>
{kind === 'directory' ? (
{kind === "directory" ? (
<Folder size={16} color={theme.colors.foregroundMuted} />
) : (
<File size={16} color={theme.colors.foregroundMuted} />
)}
</View>
) : null
) : null;
return (
<Pressable
@@ -203,7 +203,7 @@ export function ComboboxItem({
</View>
) : null}
</Pressable>
)
);
}
export function ComboboxEmpty({ children }: { children: ReactNode }): ReactElement {
@@ -211,7 +211,7 @@ export function ComboboxEmpty({ children }: { children: ReactNode }): ReactEleme
<Text testID="combobox-empty-text" style={styles.emptyText}>
{children}
</Text>
)
);
}
export function Combobox({
@@ -221,207 +221,207 @@ export function Combobox({
renderOption,
onSearchQueryChange,
searchable = true,
placeholder = 'Search...',
placeholder = "Search...",
searchPlaceholder,
emptyText = 'No options match your search.',
emptyText = "No options match your search.",
allowCustomValue = false,
customValuePrefix = 'Use',
customValuePrefix = "Use",
customValueDescription,
customValueKind,
optionsPosition = 'below-search',
title = 'Select',
optionsPosition = "below-search",
title = "Select",
open,
onOpenChange,
enableDismissOnClose,
desktopPlacement = 'top-start',
desktopPlacement = "top-start",
desktopPreventInitialFlash = true,
anchorRef,
children,
}: ComboboxProps): ReactElement {
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
const effectiveOptionsPosition = isMobile ? 'below-search' : optionsPosition
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition;
const isDesktopAboveSearch =
!isMobile && Platform.OS === 'web' && effectiveOptionsPosition === 'above-search'
const { height: windowHeight } = useWindowDimensions()
const bottomSheetRef = useRef<BottomSheetModal>(null)
const hasPresentedBottomSheetRef = useRef(false)
const snapPoints = useMemo(() => ['60%', '90%'], [])
!isMobile && Platform.OS === "web" && effectiveOptionsPosition === "above-search";
const { height: windowHeight } = useWindowDimensions();
const bottomSheetRef = useRef<BottomSheetModal>(null);
const hasPresentedBottomSheetRef = useRef(false);
const snapPoints = useMemo(() => ["60%", "90%"], []);
const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(
null
)
const [referenceWidth, setReferenceWidth] = useState<number | null>(null)
const [referenceTop, setReferenceTop] = useState<number | null>(null)
const [referenceAtOrigin, setReferenceAtOrigin] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [activeIndex, setActiveIndex] = useState<number>(-1)
const desktopOptionsScrollRef = useRef<ScrollView>(null)
null,
);
const [referenceWidth, setReferenceWidth] = useState<number | null>(null);
const [referenceTop, setReferenceTop] = useState<number | null>(null);
const [referenceAtOrigin, setReferenceAtOrigin] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [activeIndex, setActiveIndex] = useState<number>(-1);
const desktopOptionsScrollRef = useRef<ScrollView>(null);
const isControlled = typeof open === 'boolean'
const [internalOpen, setInternalOpen] = useState(false)
const isOpen = isControlled ? open : internalOpen
const isControlled = typeof open === "boolean";
const [internalOpen, setInternalOpen] = useState(false);
const isOpen = isControlled ? open : internalOpen;
const setOpen = useCallback(
(nextOpen: boolean) => {
if (!isControlled) {
setInternalOpen(nextOpen)
setInternalOpen(nextOpen);
}
onOpenChange?.(nextOpen)
onOpenChange?.(nextOpen);
},
[isControlled, onOpenChange]
)
[isControlled, onOpenChange],
);
const setSearchQueryWithCallback = useCallback(
(nextQuery: string) => {
setSearchQuery(nextQuery)
onSearchQueryChange?.(nextQuery)
setSearchQuery(nextQuery);
onSearchQueryChange?.(nextQuery);
},
[onSearchQueryChange]
)
[onSearchQueryChange],
);
const handleClose = useCallback(() => {
setOpen(false)
setSearchQueryWithCallback('')
}, [setOpen, setSearchQueryWithCallback])
setOpen(false);
setSearchQueryWithCallback("");
}, [setOpen, setSearchQueryWithCallback]);
useEffect(() => {
if (isOpen) {
setSearchQueryWithCallback('')
setSearchQueryWithCallback("");
}
}, [isOpen, setSearchQueryWithCallback])
}, [isOpen, setSearchQueryWithCallback]);
const collisionPadding = useMemo(() => {
const basePadding = 16
if (Platform.OS !== 'android') return basePadding
const statusBarHeight = StatusBar.currentHeight ?? 0
return Math.max(basePadding, statusBarHeight + basePadding)
}, [])
const basePadding = 16;
if (Platform.OS !== "android") return basePadding;
const statusBarHeight = StatusBar.currentHeight ?? 0;
return Math.max(basePadding, statusBarHeight + basePadding);
}, []);
const middleware = useMemo(
() => [
floatingOffset(Platform.OS === 'web' ? 0 : 4),
...(Platform.OS === 'web' ? [] : [flip({ padding: collisionPadding })]),
floatingOffset(Platform.OS === "web" ? 0 : 4),
...(Platform.OS === "web" ? [] : [flip({ padding: collisionPadding })]),
...(isDesktopAboveSearch ? [] : [shift({ padding: collisionPadding })]),
floatingSize({
padding: collisionPadding,
apply({ availableWidth, availableHeight, rects }) {
setAvailableSize((prev) => {
const next = { width: availableWidth, height: availableHeight }
if (!prev) return next
if (prev.width === next.width && prev.height === next.height) return prev
return next
})
const next = { width: availableWidth, height: availableHeight };
if (!prev) return next;
if (prev.width === next.width && prev.height === next.height) return prev;
return next;
});
setReferenceWidth((prev) => {
const next = rects.reference.width
if (prev === next) return prev
return next
})
const next = rects.reference.width;
if (prev === next) return prev;
return next;
});
},
}),
],
[collisionPadding, isDesktopAboveSearch]
)
[collisionPadding, isDesktopAboveSearch],
);
const { refs, floatingStyles, update } = useFloating({
placement: Platform.OS === 'web' ? desktopPlacement : 'bottom-start',
placement: Platform.OS === "web" ? desktopPlacement : "bottom-start",
middleware,
sameScrollView: false,
elements: {
reference: anchorRef.current ?? undefined,
},
})
});
useEffect(() => {
if (!isOpen || isMobile) {
setAvailableSize(null)
setReferenceWidth(null)
return
setAvailableSize(null);
setReferenceWidth(null);
return;
}
const raf = requestAnimationFrame(() => void update())
return () => cancelAnimationFrame(raf)
}, [desktopPlacement, isMobile, update, isOpen])
const raf = requestAnimationFrame(() => void update());
return () => cancelAnimationFrame(raf);
}, [desktopPlacement, isMobile, update, isOpen]);
useEffect(() => {
if (!isOpen || isMobile) {
setReferenceAtOrigin(false)
setReferenceTop(null)
return
setReferenceAtOrigin(false);
setReferenceTop(null);
return;
}
const referenceEl = anchorRef.current
const referenceEl = anchorRef.current;
if (!referenceEl) {
setReferenceAtOrigin(false)
setReferenceTop(null)
return
setReferenceAtOrigin(false);
setReferenceTop(null);
return;
}
const measure = () => {
referenceEl.measureInWindow((x, y) => {
setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1)
setReferenceTop((prev) => (prev === y ? prev : y))
})
}
setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1);
setReferenceTop((prev) => (prev === y ? prev : y));
});
};
measure()
const raf = requestAnimationFrame(measure)
return () => cancelAnimationFrame(raf)
}, [anchorRef, isMobile, isOpen, searchQuery, windowHeight])
measure();
const raf = requestAnimationFrame(measure);
return () => cancelAnimationFrame(raf);
}, [anchorRef, isMobile, isOpen, searchQuery, windowHeight]);
const floatingTop = toNumericStyleValue(floatingStyles.top)
const floatingLeft = toNumericStyleValue(floatingStyles.left)
const floatingTop = toNumericStyleValue(floatingStyles.top);
const floatingLeft = toNumericStyleValue(floatingStyles.left);
const desktopAboveSearchBottom =
isDesktopAboveSearch && referenceTop !== null
? Math.max(windowHeight - referenceTop, collisionPadding)
: null
: null;
const hasResolvedDesktopPosition =
referenceWidth !== null &&
floatingLeft !== null &&
(isDesktopAboveSearch ? desktopAboveSearchBottom !== null : floatingTop !== null) &&
((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin)
const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition
const shouldUseDesktopFade = !desktopPreventInitialFlash
((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin);
const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition;
const shouldUseDesktopFade = !desktopPreventInitialFlash;
const desktopPositionStyle = isDesktopAboveSearch
? {
left: floatingLeft ?? 0,
bottom: desktopAboveSearchBottom ?? 0,
}
: floatingStyles
: floatingStyles;
useEffect(() => {
if (!isMobile) return
if (!isMobile) return;
if (isOpen) {
if (enableDismissOnClose === false && hasPresentedBottomSheetRef.current) {
bottomSheetRef.current?.snapToIndex(0)
bottomSheetRef.current?.snapToIndex(0);
} else {
hasPresentedBottomSheetRef.current = true
bottomSheetRef.current?.present()
hasPresentedBottomSheetRef.current = true;
bottomSheetRef.current?.present();
}
} else {
if (enableDismissOnClose === false) {
bottomSheetRef.current?.close()
bottomSheetRef.current?.close();
} else {
bottomSheetRef.current?.dismiss()
bottomSheetRef.current?.dismiss();
}
}
}, [enableDismissOnClose, isOpen, isMobile])
}, [enableDismissOnClose, isOpen, isMobile]);
const handleSheetChange = useCallback(
(index: number) => {
if (index === -1) {
handleClose()
handleClose();
}
},
[handleClose]
)
[handleClose],
);
const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
),
[]
)
[],
);
const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : ''
const sanitizedSearchValue = searchQuery.trim()
const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : "";
const sanitizedSearchValue = searchQuery.trim();
const showCustomOption = useMemo(
() =>
shouldShowCustomComboboxOption({
@@ -430,8 +430,8 @@ export function Combobox({
searchable,
allowCustomValue,
}),
[allowCustomValue, options, searchQuery, searchable]
)
[allowCustomValue, options, searchQuery, searchable],
);
const visibleOptions = useMemo(
() =>
@@ -452,133 +452,133 @@ export function Combobox({
options,
searchQuery,
searchable,
]
)
],
);
const orderedVisibleOptions = useMemo(
() => orderVisibleComboboxOptions(visibleOptions, effectiveOptionsPosition),
[effectiveOptionsPosition, visibleOptions]
)
[effectiveOptionsPosition, visibleOptions],
);
const pinDesktopOptionsToBottom = useCallback(() => {
if (isMobile || effectiveOptionsPosition !== 'above-search') {
return
if (isMobile || effectiveOptionsPosition !== "above-search") {
return;
}
desktopOptionsScrollRef.current?.scrollToEnd({ animated: false })
desktopOptionsScrollRef.current?.scrollToEnd({ animated: false });
requestAnimationFrame(() => {
desktopOptionsScrollRef.current?.scrollToEnd({ animated: false })
})
}, [effectiveOptionsPosition, isMobile])
desktopOptionsScrollRef.current?.scrollToEnd({ animated: false });
});
}, [effectiveOptionsPosition, isMobile]);
const handleDesktopOptionsContentSizeChange = useCallback(() => {
if (!isOpen) {
return
return;
}
pinDesktopOptionsToBottom()
}, [isOpen, pinDesktopOptionsToBottom])
pinDesktopOptionsToBottom();
}, [isOpen, pinDesktopOptionsToBottom]);
useEffect(() => {
if (!isOpen) {
return
return;
}
pinDesktopOptionsToBottom()
}, [isOpen, orderedVisibleOptions, pinDesktopOptionsToBottom])
pinDesktopOptionsToBottom();
}, [isOpen, orderedVisibleOptions, pinDesktopOptionsToBottom]);
useLayoutEffect(() => {
if (!isOpen || isMobile) {
return
return;
}
void update()
}, [isOpen, isMobile, orderedVisibleOptions.length, searchQuery, update])
void update();
}, [isOpen, isMobile, orderedVisibleOptions.length, searchQuery, update]);
useEffect(() => {
if (!isOpen) return
if (!IS_WEB && isMobile) return
if (!isOpen) return;
if (!IS_WEB && isMobile) return;
if (orderedVisibleOptions.length === 0) {
setActiveIndex(-1)
return
setActiveIndex(-1);
return;
}
const fallbackIndex = getComboboxFallbackIndex(
orderedVisibleOptions.length,
effectiveOptionsPosition
)
effectiveOptionsPosition,
);
if (normalizedSearch) {
setActiveIndex(fallbackIndex)
return
setActiveIndex(fallbackIndex);
return;
}
const selectedIndex = orderedVisibleOptions.findIndex((opt) => opt.id === value)
setActiveIndex(selectedIndex >= 0 ? selectedIndex : fallbackIndex)
}, [effectiveOptionsPosition, isMobile, isOpen, normalizedSearch, value, orderedVisibleOptions])
const selectedIndex = orderedVisibleOptions.findIndex((opt) => opt.id === value);
setActiveIndex(selectedIndex >= 0 ? selectedIndex : fallbackIndex);
}, [effectiveOptionsPosition, isMobile, isOpen, normalizedSearch, value, orderedVisibleOptions]);
const handleSelect = useCallback(
(id: string) => {
onSelect(id)
handleClose()
onSelect(id);
handleClose();
},
[handleClose, onSelect]
)
[handleClose, onSelect],
);
const handleSubmitSearch = useCallback(() => {
if (showCustomOption) {
handleSelect(sanitizedSearchValue)
handleSelect(sanitizedSearchValue);
}
}, [handleSelect, sanitizedSearchValue, showCustomOption])
}, [handleSelect, sanitizedSearchValue, showCustomOption]);
const handleDesktopKey = useCallback(
(key: 'ArrowDown' | 'ArrowUp' | 'Enter' | 'Escape', event?: KeyboardEvent) => {
if (!isOpen) return
if (!IS_WEB && isMobile) return
(key: "ArrowDown" | "ArrowUp" | "Enter" | "Escape", event?: KeyboardEvent) => {
if (!isOpen) return;
if (!IS_WEB && isMobile) return;
if (key === 'ArrowDown' || key === 'ArrowUp') {
event?.preventDefault()
if (key === "ArrowDown" || key === "ArrowUp") {
event?.preventDefault();
setActiveIndex((currentIndex) =>
getNextActiveIndex({
currentIndex,
itemCount: orderedVisibleOptions.length,
key,
})
)
return
}),
);
return;
}
if (key === 'Enter') {
if (orderedVisibleOptions.length === 0) return
event?.preventDefault()
if (key === "Enter") {
if (orderedVisibleOptions.length === 0) return;
event?.preventDefault();
const index =
activeIndex >= 0 && activeIndex < orderedVisibleOptions.length ? activeIndex : 0
handleSelect(orderedVisibleOptions[index]!.id)
return
activeIndex >= 0 && activeIndex < orderedVisibleOptions.length ? activeIndex : 0;
handleSelect(orderedVisibleOptions[index]!.id);
return;
}
if (key === 'Escape') {
event?.preventDefault()
handleClose()
if (key === "Escape") {
event?.preventDefault();
handleClose();
}
},
[activeIndex, handleClose, handleSelect, isMobile, isOpen, orderedVisibleOptions]
)
[activeIndex, handleClose, handleSelect, isMobile, isOpen, orderedVisibleOptions],
);
useEffect(() => {
if (!IS_WEB || !isOpen) return
if (!IS_WEB || !isOpen) return;
const handler = (event: KeyboardEvent) => {
const key = event.key
if (key !== 'ArrowDown' && key !== 'ArrowUp' && key !== 'Enter' && key !== 'Escape') {
return
const key = event.key;
if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") {
return;
}
handleDesktopKey(key, event)
}
handleDesktopKey(key, event);
};
// react-native-web's TextInput can stop propagation on key events, so listen in capture phase.
window.addEventListener('keydown', handler, true)
window.addEventListener("keydown", handler, true);
return () => {
window.removeEventListener('keydown', handler, true)
}
}, [handleDesktopKey, isOpen])
window.removeEventListener("keydown", handler, true);
};
}, [handleDesktopKey, isOpen]);
const searchInput = (
<SearchInput
@@ -588,12 +588,12 @@ export function Combobox({
onSubmitEditing={handleSubmitSearch}
autoFocus={!isMobile}
/>
)
);
const optionsList = (
<>
{orderedVisibleOptions.length > 0 ? (
orderedVisibleOptions.map((opt, index) => (
orderedVisibleOptions.map((opt, index) =>
renderOption ? (
<View key={opt.id}>
{renderOption({
@@ -613,23 +613,23 @@ export function Combobox({
active={index === activeIndex}
onPress={() => handleSelect(opt.id)}
/>
)
))
),
)
) : (
<ComboboxEmpty>{emptyText}</ComboboxEmpty>
)}
</>
)
);
const defaultContent = (
<>
{effectiveOptionsPosition === 'above-search' ? optionsList : null}
{effectiveOptionsPosition === "above-search" ? optionsList : null}
{searchable ? searchInput : null}
{effectiveOptionsPosition === 'below-search' ? optionsList : null}
{effectiveOptionsPosition === "below-search" ? optionsList : null}
</>
)
);
const content = children ?? defaultContent
const content = children ?? defaultContent;
if (isMobile) {
return (
@@ -658,10 +658,10 @@ export function Combobox({
{content}
</BottomSheetScrollView>
</BottomSheetModal>
)
);
}
if (!isOpen) return <></>
if (!isOpen) return <></>;
return (
<Modal transparent animationType="none" visible={isOpen} onRequestClose={handleClose}>
@@ -674,13 +674,13 @@ export function Combobox({
style={[
styles.desktopContainer,
{
position: 'absolute',
position: "absolute",
minWidth: referenceWidth ?? 200,
maxWidth: 400,
},
desktopPositionStyle,
shouldHideDesktopContent ? { opacity: 0 } : null,
typeof availableSize?.height === 'number'
typeof availableSize?.height === "number"
? { maxHeight: Math.min(availableSize.height, 400) }
: null,
]}
@@ -699,7 +699,7 @@ export function Combobox({
</ScrollView>
) : (
<>
{effectiveOptionsPosition === 'above-search' ? (
{effectiveOptionsPosition === "above-search" ? (
<ScrollView
ref={desktopOptionsScrollRef}
contentContainerStyle={[
@@ -715,7 +715,7 @@ export function Combobox({
</ScrollView>
) : null}
{searchable ? searchInput : null}
{effectiveOptionsPosition === 'below-search' ? (
{effectiveOptionsPosition === "below-search" ? (
<ScrollView
contentContainerStyle={styles.desktopScrollContent}
keyboardShouldPersistTaps="handled"
@@ -730,13 +730,13 @@ export function Combobox({
</Animated.View>
</View>
</Modal>
)
);
}
const styles = StyleSheet.create((theme) => ({
searchInputContainer: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface1,
@@ -754,8 +754,8 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
},
comboboxItem: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
minHeight: 36,
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
@@ -779,9 +779,9 @@ const styles = StyleSheet.create((theme) => ({
},
comboboxItemTrailingSlot: {
width: 16,
alignItems: 'center',
justifyContent: 'center',
marginLeft: 'auto',
alignItems: "center",
justifyContent: "center",
marginLeft: "auto",
},
comboboxItemContent: {
flex: 1,
@@ -789,8 +789,8 @@ const styles = StyleSheet.create((theme) => ({
},
comboboxItemLeadingSlot: {
width: 16,
alignItems: 'center',
justifyContent: 'center',
alignItems: "center",
justifyContent: "center",
},
comboboxItemLabel: {
fontSize: theme.fontSize.sm,
@@ -809,8 +809,8 @@ const styles = StyleSheet.create((theme) => ({
},
bottomSheetBackground: {
backgroundColor: theme.colors.surface0,
borderTopLeftRadius: theme.borderRadius['2xl'],
borderTopRightRadius: theme.borderRadius['2xl'],
borderTopLeftRadius: theme.borderRadius["2xl"],
borderTopRightRadius: theme.borderRadius["2xl"],
},
bottomSheetHandle: {
backgroundColor: theme.colors.palette.zinc[600],
@@ -823,7 +823,7 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
textAlign: 'left',
textAlign: "left",
},
comboboxScrollContent: {
paddingBottom: theme.spacing[8],
@@ -834,7 +834,7 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
},
desktopBackdrop: {
position: 'absolute',
position: "absolute",
top: 0,
right: 0,
bottom: 0,
@@ -845,13 +845,13 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
shadowColor: '#000',
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 8,
maxHeight: 400,
overflow: 'hidden',
overflow: "hidden",
},
desktopScroll: {
flexShrink: 1,
@@ -862,6 +862,6 @@ const styles = StyleSheet.create((theme) => ({
},
desktopScrollContentAboveSearch: {
flexGrow: 1,
justifyContent: 'flex-end',
justifyContent: "flex-end",
},
}))
}));
+39 -29
View File
@@ -29,11 +29,7 @@ import {
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { Check, CheckCircle } from "lucide-react-native";
import {
BottomSheetBackdrop,
BottomSheetModal,
BottomSheetScrollView,
} from "@gorhom/bottom-sheet";
import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet";
// Keep parity with dropdown-menu action statuses.
export type ActionStatus = "idle" | "pending" | "success";
@@ -88,7 +84,7 @@ function useControllableOpenState({
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
},
[isControlled, onOpenChange]
[isControlled, onOpenChange],
);
return [value, setValue];
}
@@ -120,8 +116,7 @@ function computePosition({
// Calculate available space
const spaceTop = triggerRect.y - displayArea.y;
const spaceBottom =
displayArea.y + displayArea.height - (triggerRect.y + triggerRect.height);
const spaceBottom = displayArea.y + displayArea.height - (triggerRect.y + triggerRect.height);
// Flip if needed
let actualPlacement = placement;
@@ -163,7 +158,7 @@ function computePosition({
x = Math.max(padding, Math.min(displayArea.width - contentWidth - padding, x!));
y = Math.max(
displayArea.y + padding,
Math.min(displayArea.y + displayArea.height - contentHeight - padding, y!)
Math.min(displayArea.y + displayArea.height - contentHeight - padding, y!),
);
return { x, y, actualPlacement };
@@ -226,7 +221,7 @@ export function ContextMenu({
anchorRect,
setAnchorRect,
}),
[anchorRect, isOpen, setAnchorRect, setIsOpen]
[anchorRect, isOpen, setAnchorRect, setIsOpen],
);
return <ContextMenuContext.Provider value={value}>{children}</ContextMenuContext.Provider>;
@@ -258,8 +253,7 @@ export function ContextMenuTrigger({
const ctx = useContextMenuContext("ContextMenuTrigger");
const shouldEnableOnThisPlatform =
enabled &&
(Platform.OS === "web" ? enabledOnWeb : enabledOnMobile);
enabled && (Platform.OS === "web" ? enabledOnWeb : enabledOnMobile);
const openAtEvent = useCallback(
(event: unknown) => {
@@ -279,7 +273,7 @@ export function ContextMenuTrigger({
});
ctx.setOpen(true);
},
[ctx, disabled, shouldEnableOnThisPlatform]
[ctx, disabled, shouldEnableOnThisPlatform],
);
const handleRef = useCallback(
@@ -287,7 +281,7 @@ export function ContextMenuTrigger({
assignRef(ctx.triggerRef, node);
assignRef(triggerRef, node);
},
[ctx.triggerRef, triggerRef]
[ctx.triggerRef, triggerRef],
);
return (
@@ -380,19 +374,14 @@ export function ContextMenuContent({
handleClose();
}
},
[handleClose]
[handleClose],
);
const renderSheetBackdrop = useCallback(
(props: ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop
{...props}
appearsOnIndex={0}
disappearsOnIndex={-1}
opacity={0.45}
/>
<BottomSheetBackdrop {...props} appearsOnIndex={0} disappearsOnIndex={-1} opacity={0.45} />
),
[]
[],
);
// Measure trigger when opening (fallback) and capture point anchors.
@@ -468,7 +457,7 @@ export function ContextMenuContent({
const { width: w, height: h } = event.nativeEvent.layout;
setContentSize({ width: w, height: h });
},
[]
[],
);
if (useMobileSheet) {
@@ -543,7 +532,11 @@ export function ContextMenuContent({
},
]}
>
<ScrollView bounces={false} showsVerticalScrollIndicator contentContainerStyle={{ flexGrow: 1 }}>
<ScrollView
bounces={false}
showsVerticalScrollIndicator
contentContainerStyle={{ flexGrow: 1 }}
>
{children}
</ScrollView>
</Animated.View>
@@ -564,7 +557,13 @@ export function ContextMenuLabel({
);
}
export function ContextMenuSeparator({ style, testID }: { style?: ViewStyle; testID?: string }): ReactElement {
export function ContextMenuSeparator({
style,
testID,
}: {
style?: ViewStyle;
testID?: string;
}): ReactElement {
return <View style={[styles.separator, style]} testID={testID} />;
}
@@ -638,7 +637,10 @@ export function ContextMenuItem({
}
const trailingContent =
trailing ?? (!showSelectedCheck && selected ? <Check size={16} color={theme.colors.foregroundMuted} /> : null);
trailing ??
(!showSelectedCheck && selected ? (
<Check size={16} color={theme.colors.foregroundMuted} />
) : null);
return (
<Pressable
@@ -654,8 +656,14 @@ export function ContextMenuItem({
}}
style={({ pressed, hovered }) => [
styles.item,
selected ? (selectedVariant === "accent" ? styles.itemSelectedAccent : styles.itemSelected) : null,
selected && (hovered || pressed) && selectedVariant !== "accent" ? styles.itemSelectedInteractive : null,
selected
? selectedVariant === "accent"
? styles.itemSelectedAccent
: styles.itemSelected
: null,
selected && (hovered || pressed) && selectedVariant !== "accent"
? styles.itemSelectedInteractive
: null,
isDisabled ? styles.itemDisabled : null,
hovered && !pressed && !isDisabled ? styles.itemHovered : null,
pressed && !isDisabled ? styles.itemPressed : null,
@@ -684,7 +692,9 @@ export function ContextMenuItem({
numberOfLines={2}
style={[
styles.itemDescription,
selected && selectedVariant === "accent" ? styles.itemDescriptionSelectedAccent : null,
selected && selectedVariant === "accent"
? styles.itemDescriptionSelectedAccent
: null,
]}
>
{description}
@@ -170,7 +170,6 @@ export function useDropdownFloating({
}, []);
const update = useCallback(async () => {
if (!referenceEl || !floatingElRef.current) {
return;
}
@@ -209,7 +208,6 @@ export function useDropdownFloating({
}, []);
useEffect(() => {
if (!open) {
setGeometry(null);
setFloatingReady(false);
@@ -74,7 +74,7 @@ function useControllableOpenState({
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
},
[isControlled, onOpenChange]
[isControlled, onOpenChange],
);
return [value, setValue];
}
@@ -146,7 +146,10 @@ function computePosition({
// Constrain to screen
const padding = 8;
x = Math.max(padding, Math.min(displayArea.width - contentWidth - padding, x!));
y = Math.max(displayArea.y + padding, Math.min(displayArea.y + displayArea.height - contentHeight - padding, y!));
y = Math.max(
displayArea.y + padding,
Math.min(displayArea.y + displayArea.height - contentHeight - padding, y!),
);
return { x, y, actualPlacement };
}
@@ -174,16 +177,14 @@ export function DropdownMenu({
setOpen: setIsOpen,
triggerRef,
}),
[isOpen, setIsOpen]
[isOpen, setIsOpen],
);
return <DropdownMenuContext.Provider value={value}>{children}</DropdownMenuContext.Provider>;
}
type TriggerState = { pressed: boolean; hovered: boolean; open: boolean };
type TriggerStyleProp =
| StyleProp<ViewStyle>
| ((state: TriggerState) => StyleProp<ViewStyle>);
type TriggerStyleProp = StyleProp<ViewStyle> | ((state: TriggerState) => StyleProp<ViewStyle>);
interface DropdownMenuTriggerProps extends Omit<PressableProps, "style" | "children"> {
style?: TriggerStyleProp;
@@ -315,10 +316,13 @@ export function DropdownMenuContent({
setPosition({ x, y: result.y });
}, [triggerRect, contentSize, side, align, offset, fullWidth, horizontalPadding]);
const handleContentLayout = useCallback((event: { nativeEvent: { layout: { width: number; height: number } } }) => {
const { width: w, height: h } = event.nativeEvent.layout;
setContentSize({ width: w, height: h });
}, []);
const handleContentLayout = useCallback(
(event: { nativeEvent: { layout: { width: number; height: number } } }) => {
const { width: w, height: h } = event.nativeEvent.layout;
setContentSize({ width: w, height: h });
},
[],
);
if (!open) return null;
@@ -363,7 +367,11 @@ export function DropdownMenuContent({
},
]}
>
<ScrollView bounces={false} showsVerticalScrollIndicator contentContainerStyle={{ flexGrow: 1 }}>
<ScrollView
bounces={false}
showsVerticalScrollIndicator
contentContainerStyle={{ flexGrow: 1 }}
>
{children}
</ScrollView>
</Animated.View>
@@ -384,7 +392,13 @@ export function DropdownMenuLabel({
);
}
export function DropdownMenuSeparator({ style, testID }: { style?: ViewStyle; testID?: string }): ReactElement {
export function DropdownMenuSeparator({
style,
testID,
}: {
style?: ViewStyle;
testID?: string;
}): ReactElement {
return <View style={[styles.separator, style]} testID={testID} />;
}
@@ -464,7 +478,10 @@ export function DropdownMenuItem({
}
const trailingContent =
trailing ?? (!showSelectedCheck && selected ? <Check size={16} color={theme.colors.foregroundMuted} /> : null);
trailing ??
(!showSelectedCheck && selected ? (
<Check size={16} color={theme.colors.foregroundMuted} />
) : null);
return (
<Pressable
@@ -480,7 +497,11 @@ export function DropdownMenuItem({
}}
style={({ pressed, hovered }) => [
styles.item,
selected ? (selectedVariant === "accent" ? styles.itemSelectedAccent : styles.itemSelected) : null,
selected
? selectedVariant === "accent"
? styles.itemSelectedAccent
: styles.itemSelected
: null,
selected && (hovered || pressed) && selectedVariant !== "accent"
? styles.itemSelectedInteractive
: null,
@@ -512,7 +533,9 @@ export function DropdownMenuItem({
numberOfLines={2}
style={[
styles.itemDescription,
selected && selectedVariant === "accent" ? styles.itemDescriptionSelectedAccent : null,
selected && selectedVariant === "accent"
? styles.itemDescriptionSelectedAccent
: null,
]}
>
{description}
@@ -5,10 +5,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
type SegmentedControlSize = "sm" | "md";
type SegmentedControlIconRenderer = (props: {
color: string;
size: number;
}) => ReactNode;
type SegmentedControlIconRenderer = (props: { color: string; size: number }) => ReactNode;
export type SegmentedControlOption<T extends string> = {
value: T;
+16 -28
View File
@@ -59,7 +59,7 @@ function useTooltipContext(componentName: string): TooltipContextValue {
function composeEventHandlers<E>(
original?: (event: E) => void,
injected?: (event: E) => void
injected?: (event: E) => void,
): (event: E) => void {
return (event: E) => {
original?.(event);
@@ -94,7 +94,7 @@ function useControllableOpenState({
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
},
[isControlled, onOpenChange]
[isControlled, onOpenChange],
);
return [value, setValue];
}
@@ -125,11 +125,9 @@ function computePosition({
const { width: contentWidth, height: contentHeight } = contentSize;
const spaceTop = triggerRect.y - displayArea.y;
const spaceBottom =
displayArea.y + displayArea.height - (triggerRect.y + triggerRect.height);
const spaceBottom = displayArea.y + displayArea.height - (triggerRect.y + triggerRect.height);
const spaceLeft = triggerRect.x - displayArea.x;
const spaceRight =
displayArea.x + displayArea.width - (triggerRect.x + triggerRect.width);
const spaceRight = displayArea.x + displayArea.width - (triggerRect.x + triggerRect.width);
let actualSide = side;
if (side === "bottom" && spaceBottom < contentHeight && spaceTop > spaceBottom) {
@@ -184,13 +182,10 @@ function computePosition({
}
const padding = 8;
x = Math.max(
padding,
Math.min(displayArea.width - contentWidth - padding, x)
);
x = Math.max(padding, Math.min(displayArea.width - contentWidth - padding, x));
y = Math.max(
displayArea.y + padding,
Math.min(displayArea.y + displayArea.height - contentHeight - padding, y)
Math.min(displayArea.y + displayArea.height - contentHeight - padding, y),
);
return { x, y, actualSide };
@@ -234,7 +229,7 @@ export function Tooltip({
enabled,
delayDuration,
}),
[isOpen, setIsOpen, enabled, delayDuration]
[isOpen, setIsOpen, enabled, delayDuration],
);
return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
@@ -296,7 +291,7 @@ export function TooltipTrigger({
onHoverIn?.(e);
scheduleOpen();
},
[onHoverIn, scheduleOpen]
[onHoverIn, scheduleOpen],
);
const handleHoverOut = useCallback(
@@ -304,7 +299,7 @@ export function TooltipTrigger({
onHoverOut?.(e);
close();
},
[onHoverOut, close]
[onHoverOut, close],
);
const handleFocus = useCallback(
@@ -314,7 +309,7 @@ export function TooltipTrigger({
clearOpenTimer();
ctx.setOpen(true);
},
[clearOpenTimer, ctx, disabled, onFocus]
[clearOpenTimer, ctx, disabled, onFocus],
);
const handleBlur = useCallback(
@@ -322,7 +317,7 @@ export function TooltipTrigger({
onBlur?.(e);
close();
},
[close, onBlur]
[close, onBlur],
);
const handlePress = useCallback(
@@ -330,7 +325,7 @@ export function TooltipTrigger({
onPress?.(e);
close();
},
[close, onPress]
[close, onPress],
);
const triggerProps = {
@@ -383,11 +378,7 @@ export function TooltipTrigger({
}
return (
<Pressable
{...triggerProps}
ref={ctx.triggerRef}
collapsable={false}
>
<Pressable {...triggerProps} ref={ctx.triggerRef} collapsable={false}>
{children}
</Pressable>
);
@@ -412,9 +403,7 @@ export function TooltipContent({
const ctx = useTooltipContext("TooltipContent");
const bottomSheetInternal = useBottomSheetModalInternal(true);
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(
null
);
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
useEffect(() => {
@@ -425,8 +414,7 @@ export function TooltipContent({
return;
}
const statusBarHeight =
Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
let cancelled = false;
measureElement(ctx.triggerRef.current).then((rect) => {
@@ -459,7 +447,7 @@ export function TooltipContent({
const { width, height } = event.nativeEvent.layout;
setContentSize({ width, height });
},
[]
[],
);
if (!ctx.open || !ctx.enabled) return null;
@@ -17,7 +17,7 @@ function createMeasurementState(
offsetY: number;
viewportMeasuredForKey: string | null;
contentMeasuredForKey: string | null;
}>
}>,
) {
return {
containerKey: "scroll-view",
@@ -119,11 +119,10 @@ function createDriverHarness(input?: {
agentId: "agent-1",
authoritativeReady: input?.authoritativeReady ?? true,
renderStrategy: "forward-stream",
transportBehavior:
input?.transportBehavior ?? {
verificationDelayFrames: 0,
verificationRetryMode: "rescroll",
},
transportBehavior: input?.transportBehavior ?? {
verificationDelayFrames: 0,
verificationRetryMode: "rescroll",
},
measurementState,
nearBottom: input?.isNearBottom ?? true,
};
@@ -166,7 +165,7 @@ describe("deriveBottomAnchorBlockedReason", () => {
isAuthoritativeHistoryReady: false,
measurementState: createMeasurementState(),
pendingVerificationRequestId: null,
})
}),
).toBe("waiting_for_history_readiness");
expect(
@@ -178,7 +177,7 @@ describe("deriveBottomAnchorBlockedReason", () => {
viewportMeasuredForKey: "scroll-view",
}),
pendingVerificationRequestId: null,
})
}),
).toBe("waiting_for_measurable_content");
expect(
@@ -192,7 +191,7 @@ describe("deriveBottomAnchorBlockedReason", () => {
contentMeasuredForKey: "scroll-view",
}),
pendingVerificationRequestId: pendingRequest.id,
})
}),
).toBe("waiting_for_post_layout_verification");
});
});
@@ -440,7 +439,7 @@ describe("bottom anchor controller driver", () => {
harness.context.measurementState.offsetY = Math.max(
0,
harness.context.measurementState.contentHeight -
harness.context.measurementState.viewportHeight
harness.context.measurementState.viewportHeight,
);
harness.context.nearBottom = true;
});
@@ -575,7 +574,7 @@ describe("controller helper predicates", () => {
viewportMeasuredForKey: "scroll-view",
contentMeasuredForKey: "scroll-view",
}),
})
}),
).toBe("waiting_for_measurable_viewport");
});
@@ -590,7 +589,7 @@ describe("controller helper predicates", () => {
viewportMeasuredForKey: "scroll-view",
contentMeasuredForKey: "scroll-view",
}),
})
}),
).toBe("waiting_for_history_readiness");
expect(
@@ -603,7 +602,7 @@ describe("controller helper predicates", () => {
viewportMeasuredForKey: "scroll-view",
contentMeasuredForKey: "scroll-view",
}),
})
}),
).toBeNull();
});
@@ -615,7 +614,7 @@ describe("controller helper predicates", () => {
mode,
previousContentHeight: 1000,
contentHeight: 1100,
})
}),
).toBe(false);
expect(
__private__.shouldRestickOnViewportChange({
@@ -624,7 +623,7 @@ describe("controller helper predicates", () => {
viewportWidth: 640,
previousViewportHeight: 400,
viewportHeight: 360,
})
}),
).toBe(false);
});
@@ -637,7 +636,7 @@ describe("controller helper predicates", () => {
hasPendingRequest: true,
hasPendingVerification: false,
hasUnverifiedStickyMeasurementChange: false,
})
}),
).toBe(false);
expect(
@@ -648,7 +647,7 @@ describe("controller helper predicates", () => {
hasPendingRequest: false,
hasPendingVerification: true,
hasUnverifiedStickyMeasurementChange: false,
})
}),
).toBe(false);
expect(
@@ -659,7 +658,7 @@ describe("controller helper predicates", () => {
hasPendingRequest: false,
hasPendingVerification: false,
hasUnverifiedStickyMeasurementChange: true,
})
}),
).toBe(false);
expect(
@@ -670,7 +669,7 @@ describe("controller helper predicates", () => {
hasPendingRequest: false,
hasPendingVerification: false,
hasUnverifiedStickyMeasurementChange: false,
})
}),
).toBe(true);
});
@@ -683,7 +682,7 @@ describe("controller helper predicates", () => {
hasPendingRequest: false,
hasPendingVerification: false,
hasUnverifiedStickyMeasurementChange: true,
})
}),
).toBe(true);
});
});
@@ -222,9 +222,7 @@ function deriveRetryDisposition(input: {
if (input.mode !== "sticky-bottom" || input.retries >= MAX_VERIFICATION_RETRIES) {
return "fail";
}
return input.verificationRetryMode === "recheck"
? "retry-verify"
: "retry-scroll";
return input.verificationRetryMode === "recheck" ? "retry-verify" : "retry-scroll";
}
function shouldRequireRouteRequestConfirmation(input: {
@@ -235,10 +233,7 @@ function shouldRequireRouteRequestConfirmation(input: {
if (!input.request) {
return false;
}
if (
input.request.reason !== "initial-entry" &&
input.request.reason !== "resume"
) {
if (input.request.reason !== "initial-entry" && input.request.reason !== "resume") {
return false;
}
if (input.measurementState.containerKey !== "web-partial-virtualized") {
@@ -248,12 +243,11 @@ function shouldRequireRouteRequestConfirmation(input: {
}
function getDetailedMeasurementState(
measurementState: ControllerMeasurementState
measurementState: ControllerMeasurementState,
): Record<string, unknown> {
const distanceFromBottom = Math.max(
0,
measurementState.contentHeight -
(measurementState.offsetY + measurementState.viewportHeight)
measurementState.contentHeight - (measurementState.offsetY + measurementState.viewportHeight),
);
return {
containerKey: measurementState.containerKey,
@@ -268,7 +262,7 @@ function getDetailedMeasurementState(
}
function createBottomAnchorControllerDriver(
input: CreateBottomAnchorControllerDriverInput
input: CreateBottomAnchorControllerDriverInput,
): BottomAnchorControllerDriver {
let requestSequence = 0;
let mode: BottomAnchorMode = "sticky-bottom";
@@ -285,8 +279,7 @@ function createBottomAnchorControllerDriver(
const measurementState = input.getMeasurementState();
const distanceFromBottom = Math.max(
0,
measurementState.contentHeight -
(measurementState.offsetY + measurementState.viewportHeight)
measurementState.contentHeight - (measurementState.offsetY + measurementState.viewportHeight),
);
return {
agentId: input.getAgentId(),
@@ -355,34 +348,27 @@ function createBottomAnchorControllerDriver(
setBlockedReason(null);
};
const deriveDriverBlockedReason = (
measurementState: ControllerMeasurementState
) =>
const deriveDriverBlockedReason = (measurementState: ControllerMeasurementState) =>
deriveBottomAnchorBlockedReason({
pendingRequest,
isAuthoritativeHistoryReady: input.getIsAuthoritativeHistoryReady(),
measurementState,
pendingVerificationRequestId:
verificationHandle !== null ? pendingVerification?.requestId ?? null : null,
verificationHandle !== null ? (pendingVerification?.requestId ?? null) : null,
});
const scheduleVerification = (
attemptContext: AttemptContext,
delayFramesOverride?: number
) => {
const scheduleVerification = (attemptContext: AttemptContext, delayFramesOverride?: number) => {
const scheduledMeasurementState = input.getMeasurementState();
if (verificationHandle) {
input.cancelFrame(verificationHandle);
}
verificationHandle = input.scheduleFrame({
kind: "verification",
delayFrames:
delayFramesOverride ?? input.getTransportBehavior().verificationDelayFrames,
delayFrames: delayFramesOverride ?? input.getTransportBehavior().verificationDelayFrames,
callback: () => {
verificationHandle = null;
const currentRequest = pendingRequest;
const isRequestAttempt =
currentRequest && attemptContext.requestId === currentRequest.id;
const isRequestAttempt = currentRequest && attemptContext.requestId === currentRequest.id;
const measurementState = input.getMeasurementState();
const verificationBlockedReason = deriveVerificationBlockedReason({
isAuthoritativeHistoryReady: input.getIsAuthoritativeHistoryReady(),
@@ -401,8 +387,7 @@ function createBottomAnchorControllerDriver(
: deriveRetryDisposition({
mode,
retries: attemptContext.retries,
verificationRetryMode:
input.getTransportBehavior().verificationRetryMode,
verificationRetryMode: input.getTransportBehavior().verificationRetryMode,
});
if (verifiedNearBottom) {
@@ -421,7 +406,7 @@ function createBottomAnchorControllerDriver(
setBlockedReason("waiting_for_post_layout_verification");
scheduleVerification(
pendingVerification,
WEB_PARTIAL_VIRTUALIZED_CONFIRMATION_DELAY_FRAMES
WEB_PARTIAL_VIRTUALIZED_CONFIRMATION_DELAY_FRAMES,
);
return;
}
@@ -454,9 +439,7 @@ function createBottomAnchorControllerDriver(
}
pendingVerification = null;
setBlockedReason(
isRequestAttempt ? "waiting_for_post_layout_verification" : null
);
setBlockedReason(isRequestAttempt ? "waiting_for_post_layout_verification" : null);
},
});
};
@@ -485,7 +468,7 @@ function createBottomAnchorControllerDriver(
| "scroll_near_bottom_change"
| "history_readiness_change"
| "manual_reevaluate"
| "retry_scroll"
| "retry_scroll",
) => {
if (attemptHandle) {
return;
@@ -501,14 +484,9 @@ function createBottomAnchorControllerDriver(
const shouldAttemptForPendingRequest =
pendingRequest !== null && nextBlockedReason === null;
const shouldAttemptForStickyVerification =
mode === "sticky-bottom" &&
pendingVerification !== null &&
nextBlockedReason === null;
mode === "sticky-bottom" && pendingVerification !== null && nextBlockedReason === null;
if (
!shouldAttemptForPendingRequest &&
!shouldAttemptForStickyVerification
) {
if (!shouldAttemptForPendingRequest && !shouldAttemptForStickyVerification) {
return;
}
@@ -534,7 +512,7 @@ function createBottomAnchorControllerDriver(
setModeInternal(
"requestKey" in request
? "sticky-bottom"
: __private__.deriveModeForLocalRequest({ reason: request.reason })
: __private__.deriveModeForLocalRequest({ reason: request.reason }),
);
evaluate(request.reason === "jump-to-bottom", "request_created");
};
@@ -654,11 +632,7 @@ function createBottomAnchorControllerDriver(
this.detachByUser();
return;
}
if (
mode === "sticky-bottom" &&
!nextIsNearBottom &&
hasUnverifiedStickyMeasurementChange
) {
if (mode === "sticky-bottom" && !nextIsNearBottom && hasUnverifiedStickyMeasurementChange) {
if (!pendingRequest && !pendingVerification) {
pendingVerification = { requestId: null, retries: 0 };
}
@@ -727,8 +701,7 @@ export const __private__ = {
hasPendingVerification: boolean;
hasUnverifiedStickyMeasurementChange: boolean;
}): boolean {
const scrolledAwayIntentionally =
Math.abs(input.scrollDelta) >= USER_SCROLL_AWAY_DELTA_PX;
const scrolledAwayIntentionally = Math.abs(input.scrollDelta) >= USER_SCROLL_AWAY_DELTA_PX;
return (
input.mode === "sticky-bottom" &&
!input.nextIsNearBottom &&
@@ -819,10 +792,7 @@ export function useBottomAnchorController(input: {
}) {
driverRef.current?.handleViewportMetricsChange(params);
},
handleContentSizeChange(params: {
previousContentHeight: number;
contentHeight: number;
}) {
handleContentSizeChange(params: { previousContentHeight: number; contentHeight: number }) {
driverRef.current?.handleContentSizeChange(params);
},
prepareForStickyViewportChange() {
@@ -831,10 +801,7 @@ export function useBottomAnchorController(input: {
prepareForStickyContentChange() {
driverRef.current?.prepareForStickyContentChange();
},
handleScrollNearBottomChange(params: {
nextIsNearBottom: boolean;
scrollDelta: number;
}) {
handleScrollNearBottomChange(params: { nextIsNearBottom: boolean; scrollDelta: number }) {
driverRef.current?.handleScrollNearBottomChange(params);
},
reevaluate(animated = false) {
+32 -30
View File
@@ -1,16 +1,16 @@
import { Pressable, View, Text, Animated } from 'react-native';
import { useEffect, useRef } from 'react';
import { StyleSheet } from 'react-native-unistyles';
import { Pressable, View, Text, Animated } from "react-native";
import { useEffect, useRef } from "react";
import { StyleSheet } from "react-native-unistyles";
interface VoiceButtonProps {
state: 'idle' | 'recording' | 'processing' | 'playing';
state: "idle" | "recording" | "processing" | "playing";
onPress: () => void;
disabled?: boolean;
}
const styles = StyleSheet.create((theme) => ({
container: {
alignItems: 'center',
alignItems: "center",
gap: theme.spacing[4],
},
pressable: {
@@ -23,9 +23,9 @@ const styles = StyleSheet.create((theme) => ({
width: 80,
height: 80,
borderRadius: theme.borderRadius.full,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
alignItems: "center",
justifyContent: "center",
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
@@ -64,7 +64,7 @@ const styles = StyleSheet.create((theme) => ({
height: 6,
backgroundColor: theme.colors.primaryForeground,
borderRadius: theme.borderRadius.full,
position: 'absolute',
position: "absolute",
},
processingDotTop: {
top: 0,
@@ -80,8 +80,8 @@ const styles = StyleSheet.create((theme) => ({
},
// Playing icon
playingIconContainer: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
playingBar: {
@@ -104,10 +104,10 @@ const styles = StyleSheet.create((theme) => ({
micContainer: {
width: 24,
height: 32,
position: 'relative',
position: "relative",
},
micCapsule: {
position: 'absolute',
position: "absolute",
bottom: 0,
left: 4,
width: 16,
@@ -117,7 +117,7 @@ const styles = StyleSheet.create((theme) => ({
borderTopRightRadius: 999,
},
micBase: {
position: 'absolute',
position: "absolute",
bottom: 0,
left: 0,
width: 24,
@@ -131,7 +131,7 @@ export function VoiceButton({ state, onPress, disabled = false }: VoiceButtonPro
const pulseAnim = useRef(new Animated.Value(1)).current;
useEffect(() => {
if (state === 'recording') {
if (state === "recording") {
Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, {
@@ -144,7 +144,7 @@ export function VoiceButton({ state, onPress, disabled = false }: VoiceButtonPro
duration: 800,
useNativeDriver: true,
}),
])
]),
).start();
} else {
pulseAnim.setValue(1);
@@ -153,11 +153,11 @@ export function VoiceButton({ state, onPress, disabled = false }: VoiceButtonPro
const getButtonStyle = () => {
switch (state) {
case 'recording':
case "recording":
return styles.buttonRecording;
case 'processing':
case "processing":
return styles.buttonProcessing;
case 'playing':
case "playing":
return styles.buttonPlaying;
default:
return styles.buttonIdle;
@@ -166,9 +166,9 @@ export function VoiceButton({ state, onPress, disabled = false }: VoiceButtonPro
const getIcon = () => {
switch (state) {
case 'recording':
case "recording":
return <View style={styles.recordingIcon} />;
case 'processing':
case "processing":
return (
<View style={styles.processingIconContainer}>
<View style={[styles.processingDot, styles.processingDotTop]} />
@@ -176,7 +176,7 @@ export function VoiceButton({ state, onPress, disabled = false }: VoiceButtonPro
<View style={[styles.processingDot, styles.processingDotBottom]} />
</View>
);
case 'playing':
case "playing":
return (
<View style={styles.playingIconContainer}>
<View style={[styles.playingBar, styles.playingBar1]} />
@@ -196,14 +196,14 @@ export function VoiceButton({ state, onPress, disabled = false }: VoiceButtonPro
const getLabel = () => {
switch (state) {
case 'recording':
return 'Recording...';
case 'processing':
return 'Processing...';
case 'playing':
return 'Playing...';
case "recording":
return "Recording...";
case "processing":
return "Processing...";
case "playing":
return "Playing...";
default:
return 'Tap to speak';
return "Tap to speak";
}
};
@@ -214,7 +214,9 @@ export function VoiceButton({ state, onPress, disabled = false }: VoiceButtonPro
disabled={disabled}
style={disabled ? styles.pressableDisabled : styles.pressable}
>
<Animated.View style={[styles.button, getButtonStyle(), { transform: [{ scale: pulseAnim }] }]}>
<Animated.View
style={[styles.button, getButtonStyle(), { transform: [{ scale: pulseAnim }] }]}
>
{getIcon()}
</Animated.View>
</Pressable>

Some files were not shown because too many files have changed in this diff Show More