Update workspace and app changes
This commit is contained in:
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user