Update workspace and app changes

This commit is contained in:
Mohamed Boudra
2026-03-07 19:09:00 +07:00
parent 1d1c7058f1
commit 7d76da3249
40 changed files with 2693 additions and 976 deletions
@@ -104,6 +104,7 @@ type DraftAgentParams = {
model?: string
thinkingOptionId?: string
workingDir?: string
worktreeMode?: string
}
type DraftAgentScreenProps = {
@@ -160,6 +161,11 @@ function DraftAgentScreenContent({
const resolvedModel = getParamValue(params.model)
const resolvedThinkingOptionId = getParamValue(params.thinkingOptionId)
const resolvedWorkingDir = getParamValue(params.workingDir)
const resolvedWorktreeMode = getParamValue(params.worktreeMode)
const initialWorktreeMode =
resolvedWorktreeMode === 'create' || resolvedWorktreeMode === 'attach'
? resolvedWorktreeMode
: 'none'
const onlineServerIds = useMemo(() => {
if (daemons.length === 0) return []
@@ -234,7 +240,9 @@ function DraftAgentScreenContent({
const draftIdRef = useRef(generateDraftId())
const draftAgentIdRef = useRef(generateDraftId())
const [worktreeMode, setWorktreeMode] = useState<'none' | 'create' | 'attach'>('none')
const [worktreeMode, setWorktreeMode] = useState<'none' | 'create' | 'attach'>(
initialWorktreeMode
)
const [baseBranch, setBaseBranch] = useState('')
const [worktreeSlug, setWorktreeSlug] = useState('')
const [selectedWorktreePath, setSelectedWorktreePath] = useState('')
@@ -0,0 +1,206 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { View, Text, ScrollView } from "react-native";
import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import { FolderOpen } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { FormSelectTrigger } from "@/components/agent-form/agent-form-dropdowns";
import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import { useToast } from "@/contexts/toast-context";
import { useHostRuntimeSession } from "@/runtime/host-runtime";
import {
normalizeWorkspaceDescriptor,
useSessionStore,
} from "@/stores/session-store";
import { buildHostWorkspaceRouteWithOpenIntent } from "@/utils/host-routes";
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
export function OpenProjectScreen({ serverId }: { serverId: string }) {
const { theme } = useUnistyles();
const toast = useToast();
const { client, isConnected } = useHostRuntimeSession(serverId);
const workspaces = useSessionStore((state) => state.sessions[serverId]?.workspaces);
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
const [isDirectoryPickerOpen, setIsDirectoryPickerOpen] = useState(false);
const [directoryQuery, setDirectoryQuery] = useState("");
const [selectedPath, setSelectedPath] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const directoryAnchorRef = useRef<View>(null);
const recommendedPaths = useMemo(() => {
if (!workspaces) {
return [];
}
return Array.from(workspaces.values()).map((workspace) => workspace.projectRootPath || workspace.id);
}, [workspaces]);
const directorySuggestionsQuery = useQuery({
queryKey: ["open-project-directory-suggestions", serverId, directoryQuery],
queryFn: async () => {
if (!client) {
return [];
}
const result = await client.getDirectorySuggestions({
query: directoryQuery,
includeDirectories: true,
includeFiles: false,
limit: 30,
});
return result.entries?.flatMap((entry) =>
entry.kind === "directory" ? [entry.path] : []
) ?? [];
},
enabled: Boolean(client) && isConnected,
staleTime: 15_000,
retry: false,
});
const directoryOptions = useMemo(
() =>
buildWorkingDirectorySuggestions({
recommendedPaths,
serverPaths: directorySuggestionsQuery.data ?? [],
query: directoryQuery,
}).map((path) => ({
id: path,
label: path,
kind: "directory" as const,
})),
[directoryQuery, directorySuggestionsQuery.data, recommendedPaths]
);
const handleOpenProject = useCallback(async () => {
const trimmedPath = selectedPath.trim();
if (!trimmedPath) {
toast.error("Choose a project directory");
return;
}
if (!client) {
toast.error("Host is not connected");
return;
}
setIsSubmitting(true);
try {
const payload = await client.openProject(trimmedPath);
if (payload.error || !payload.workspace) {
throw new Error(payload.error || "Failed to open project");
}
mergeWorkspaces(serverId, [normalizeWorkspaceDescriptor(payload.workspace)]);
setHasHydratedWorkspaces(serverId, true);
router.replace(
buildHostWorkspaceRouteWithOpenIntent(serverId, payload.workspace.id, {
kind: "draft",
draftId: "new",
}) as any
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to open project");
} finally {
setIsSubmitting(false);
}
}, [client, mergeWorkspaces, selectedPath, serverId, setHasHydratedWorkspaces, toast]);
return (
<View style={styles.container}>
<ScrollView contentContainerStyle={styles.content}>
<View style={styles.card}>
<Text style={styles.eyebrow}>Workspace Registry</Text>
<Text style={styles.title}>Open project</Text>
<Text style={styles.subtitle}>
Add a local folder to the sidebar, then land inside its workspace draft tab.
</Text>
<FormSelectTrigger
controlRef={directoryAnchorRef}
containerStyle={styles.selector}
label="Project directory"
value={selectedPath}
placeholder="Choose a project directory"
onPress={() => setIsDirectoryPickerOpen(true)}
icon={<FolderOpen size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
showLabel={false}
valueEllipsizeMode="middle"
testID="open-project-directory-trigger"
/>
<View style={styles.actions}>
<Button
variant="default"
onPress={() => void handleOpenProject()}
disabled={isSubmitting || !isConnected}
testID="open-project-submit"
>
{isSubmitting ? "Opening..." : "Open project"}
</Button>
</View>
</View>
</ScrollView>
<Combobox
options={directoryOptions}
value={selectedPath}
onSelect={setSelectedPath}
onSearchQueryChange={setDirectoryQuery}
searchPlaceholder="Search directories..."
emptyText="No directories found"
allowCustomValue
customValuePrefix=""
customValueKind="directory"
optionsPosition="above-search"
title="Project directory"
open={isDirectoryPickerOpen}
onOpenChange={setIsDirectoryPickerOpen}
anchorRef={directoryAnchorRef}
/>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.surface0,
},
content: {
flexGrow: 1,
justifyContent: "center",
padding: theme.spacing[6],
},
card: {
alignSelf: "center",
width: "100%",
maxWidth: 560,
gap: theme.spacing[4],
padding: theme.spacing[6],
borderRadius: theme.borderRadius.xl,
backgroundColor: theme.colors.surface1,
borderWidth: 1,
borderColor: theme.colors.border,
},
eyebrow: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
textTransform: "uppercase",
letterSpacing: 1,
},
title: {
color: theme.colors.foreground,
fontSize: theme.fontSize["3xl"],
fontWeight: theme.fontWeight.semibold,
},
subtitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
lineHeight: 20,
},
selector: {
minHeight: 64,
},
actions: {
flexDirection: "row",
justifyContent: "flex-start",
},
}));
@@ -116,6 +116,21 @@ function decodeSegment(value: string): string {
}
}
function buildOpenIntentKey(input: {
serverId: string;
workspaceId: string;
openIntent?: WorkspaceOpenIntent | null;
}): string | null {
if (!input.openIntent) {
return null;
}
const openParam = buildWorkspaceOpenIntentParam(input.openIntent);
if (!openParam) {
return null;
}
return `${input.serverId}:${input.workspaceId}:${openParam}`;
}
export function WorkspaceScreen({
serverId,
workspaceId,
@@ -432,18 +447,43 @@ function WorkspaceScreenContent({
(state) => state.clearWorkspaceTabActionRequest
);
const consumedOpenIntentsRef = useRef(new Set<string>());
const [resolvedOpenIntentKey, setResolvedOpenIntentKey] = useState<string | null>(null);
const currentOpenIntentKey = useMemo(
() =>
buildOpenIntentKey({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
openIntent,
}),
[normalizedServerId, normalizedWorkspaceId, openIntent]
);
useEffect(() => {
if (!currentOpenIntentKey) {
if (resolvedOpenIntentKey !== null) {
setResolvedOpenIntentKey(null);
}
return;
}
if (resolvedOpenIntentKey === currentOpenIntentKey) {
return;
}
}, [currentOpenIntentKey, resolvedOpenIntentKey]);
useEffect(() => {
if (!openIntent || !persistenceKey) {
return;
}
const openParam = buildWorkspaceOpenIntentParam(openIntent);
if (!openParam) {
if (!currentOpenIntentKey) {
return;
}
const intentKey = `${normalizedServerId}:${normalizedWorkspaceId}:${openParam}`;
const intentKey = currentOpenIntentKey;
if (consumedOpenIntentsRef.current.has(intentKey)) {
if (resolvedOpenIntentKey !== intentKey) {
setResolvedOpenIntentKey(intentKey);
}
return;
}
consumedOpenIntentsRef.current.add(intentKey);
@@ -468,6 +508,7 @@ function WorkspaceScreenContent({
workspaceId: normalizedWorkspaceId,
tabId,
});
setResolvedOpenIntentKey(intentKey);
}
return;
}
@@ -488,8 +529,10 @@ function WorkspaceScreenContent({
workspaceId: normalizedWorkspaceId,
tabId,
});
setResolvedOpenIntentKey(intentKey);
}
}, [
currentOpenIntentKey,
focusTab,
openDraftTab,
openIntent,
@@ -497,8 +540,13 @@ function WorkspaceScreenContent({
persistenceKey,
normalizedServerId,
normalizedWorkspaceId,
resolvedOpenIntentKey,
]);
const unresolvedOpenIntent = currentOpenIntentKey && resolvedOpenIntentKey !== currentOpenIntentKey
? openIntent
: null;
useEffect(() => {
if (!normalizedServerId || !normalizedWorkspaceId) {
return;
@@ -586,17 +634,17 @@ function WorkspaceScreenContent({
tabOrder,
focusedTabId,
preferredTarget:
openIntent?.kind === "agent"
? { kind: "agent", agentId: openIntent.agentId }
: openIntent?.kind === "terminal"
? { kind: "terminal", terminalId: openIntent.terminalId }
: openIntent?.kind === "draft"
? { kind: "draft", draftId: openIntent.draftId }
: openIntent?.kind === "file"
? { kind: "file", path: openIntent.path }
unresolvedOpenIntent?.kind === "agent"
? { kind: "agent", agentId: unresolvedOpenIntent.agentId }
: unresolvedOpenIntent?.kind === "terminal"
? { kind: "terminal", terminalId: unresolvedOpenIntent.terminalId }
: unresolvedOpenIntent?.kind === "draft"
? { kind: "draft", draftId: unresolvedOpenIntent.draftId }
: unresolvedOpenIntent?.kind === "file"
? { kind: "file", path: unresolvedOpenIntent.path }
: null,
}),
[focusedTabId, openIntent, tabOrder, terminals, uiTabs, workspaceAgents]
[focusedTabId, tabOrder, terminals, uiTabs, unresolvedOpenIntent, workspaceAgents]
);
const activeTabId = tabModel.activeTabId;
@@ -11,6 +11,10 @@ describe('workspace source of truth consumption', () => {
const workspace: WorkspaceDescriptor = {
id: '/repo/main',
projectId: 'remote:github.com/getpaseo/paseo',
projectDisplayName: 'getpaseo/paseo',
projectRootPath: '/repo/main',
projectKind: 'git',
workspaceKind: 'local_checkout',
name: 'feat/workspace-sot',
status: 'running',
activityAt: new Date('2026-03-01T00:00:00.000Z'),