Treat the appearance setting as the rendered base size so the type ramp scales predictably. Native starts one pixel larger while web and desktop preserve the authored 14px base, with legacy values migrated on load.
486 lines
16 KiB
TypeScript
486 lines
16 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { Text, View } from "react-native";
|
|
import { StyleSheet } from "react-native-unistyles";
|
|
import { useTranslation } from "react-i18next";
|
|
import { createNameId } from "mnemonic-id";
|
|
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
|
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
|
|
import { Composer } from "@/composer";
|
|
import { ProjectIconView } from "@/components/project-icon-view";
|
|
import { ICON_SIZE } from "@/styles/theme";
|
|
import { useToast } from "@/contexts/toast-context";
|
|
import { useAgentInputDraft } from "@/composer/draft/input-draft";
|
|
import { useProjectIcon } from "@/projects/icons";
|
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
|
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
|
|
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
|
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
|
import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces";
|
|
import { encodeImages } from "@/utils/encode-images";
|
|
import { toErrorMessage } from "@/utils/error-messages";
|
|
import {
|
|
resolveComposerAttachmentSubmitFormat,
|
|
splitComposerAttachmentsForSubmit,
|
|
} from "@/composer/attachments/submit";
|
|
import type {
|
|
CreateAgentRequestOptions,
|
|
DaemonClient,
|
|
} from "@getpaseo/client/internal/daemon-client";
|
|
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
|
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
|
|
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
|
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
|
import type { MessagePayload } from "@/composer/types";
|
|
|
|
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
|
|
if (!icon) {
|
|
return null;
|
|
}
|
|
return `data:${icon.mimeType};base64,${icon.data}`;
|
|
}
|
|
|
|
const SNAP_POINTS: string[] = ["82%", "94%"];
|
|
|
|
function resolveWorkspaceTitle({
|
|
workspace,
|
|
displayName,
|
|
sourceDirectory,
|
|
}: {
|
|
workspace: { name?: string | null; projectDisplayName?: string | null } | null;
|
|
displayName: string;
|
|
sourceDirectory: string;
|
|
}): string {
|
|
return (
|
|
workspace?.name ||
|
|
workspace?.projectDisplayName ||
|
|
displayName ||
|
|
sourceDirectory.split(/[\\/]/).findLast(Boolean) ||
|
|
sourceDirectory
|
|
);
|
|
}
|
|
|
|
function buildChatDraftComposerArgs({
|
|
serverId,
|
|
isConnected,
|
|
workspaceDirectory,
|
|
sourceDirectory,
|
|
pendingWorkspaceSetup,
|
|
}: {
|
|
serverId: string;
|
|
isConnected: boolean;
|
|
workspaceDirectory: string | undefined;
|
|
sourceDirectory: string;
|
|
pendingWorkspaceSetup: { creationMethod: string } | null;
|
|
}) {
|
|
return {
|
|
initialServerId: serverId || null,
|
|
initialValues:
|
|
workspaceDirectory || sourceDirectory
|
|
? { workingDir: workspaceDirectory || sourceDirectory }
|
|
: undefined,
|
|
isVisible: pendingWorkspaceSetup !== null,
|
|
onlineServerIds: isConnected && serverId ? [serverId] : [],
|
|
lockedWorkingDir: workspaceDirectory || sourceDirectory || undefined,
|
|
};
|
|
}
|
|
|
|
async function callWorkspaceCreation({
|
|
creationMethod,
|
|
connectedClient,
|
|
input,
|
|
}: {
|
|
creationMethod: "create_worktree" | "open_project";
|
|
connectedClient: DaemonClient;
|
|
input: { cwd: string };
|
|
}) {
|
|
if (creationMethod === "create_worktree") {
|
|
return connectedClient.createPaseoWorktree({
|
|
cwd: input.cwd,
|
|
worktreeSlug: createNameId(),
|
|
});
|
|
}
|
|
return connectedClient.createWorkspace({
|
|
source: { kind: "directory", path: input.cwd },
|
|
});
|
|
}
|
|
|
|
function failureMessageForCreationMethod(
|
|
method: "create_worktree" | "open_project",
|
|
t: ReturnType<typeof useTranslation>["t"],
|
|
) {
|
|
return method === "create_worktree"
|
|
? t("workspaceSetup.errors.failedCreateWorktree")
|
|
: t("workspaceSetup.errors.failedOpenProject");
|
|
}
|
|
|
|
function buildCreateAgentOptions({
|
|
composerState,
|
|
text,
|
|
attachments,
|
|
encodedImages,
|
|
workspaceDirectory,
|
|
workspaceId,
|
|
provider,
|
|
}: {
|
|
composerState: {
|
|
modeOptions: { id: string }[];
|
|
selectedMode: string;
|
|
effectiveModelId: string | null;
|
|
effectiveThinkingOptionId: string | null;
|
|
};
|
|
text: string;
|
|
attachments: NonNullable<CreateAgentRequestOptions["attachments"]>;
|
|
encodedImages: NonNullable<CreateAgentRequestOptions["images"]> | null;
|
|
workspaceDirectory: string;
|
|
workspaceId: string;
|
|
provider: CreateAgentRequestOptions["provider"];
|
|
}): CreateAgentRequestOptions {
|
|
// Reconcile the selected mode against the discovered modes. The mode picker
|
|
// shows modeOptions[0] when the stored mode isn't in the list (e.g. a stale
|
|
// globally-remembered mode this workspace's provider config no longer
|
|
// defines), so the submitted mode must match that display rather than send a
|
|
// stale mode the provider would reject.
|
|
const modeOptionIds = composerState.modeOptions.map((mode) => mode.id);
|
|
const reconciledMode = modeOptionIds.includes(composerState.selectedMode)
|
|
? composerState.selectedMode
|
|
: (modeOptionIds[0] ?? "");
|
|
return {
|
|
provider,
|
|
cwd: workspaceDirectory,
|
|
workspaceId,
|
|
...(reconciledMode !== "" ? { modeId: reconciledMode } : {}),
|
|
...(composerState.effectiveModelId ? { model: composerState.effectiveModelId } : {}),
|
|
...(composerState.effectiveThinkingOptionId
|
|
? { thinkingOptionId: composerState.effectiveThinkingOptionId }
|
|
: {}),
|
|
...(text.trim() ? { initialPrompt: text.trim() } : {}),
|
|
...(encodedImages && encodedImages.length > 0 ? { images: encodedImages } : {}),
|
|
...(attachments.length > 0 ? { attachments } : {}),
|
|
};
|
|
}
|
|
|
|
export function WorkspaceSetupDialog() {
|
|
const { t } = useTranslation();
|
|
const toast = useToast();
|
|
const pendingWorkspaceSetup = useWorkspaceSetupStore((state) => state.pendingWorkspaceSetup);
|
|
const clearWorkspaceSetup = useWorkspaceSetupStore((state) => state.clearWorkspaceSetup);
|
|
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
|
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
|
|
const setAgents = useSessionStore((state) => state.setAgents);
|
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
const [createdWorkspace, setCreatedWorkspace] = useState<ReturnType<
|
|
typeof normalizeWorkspaceDescriptor
|
|
> | null>(null);
|
|
const [pendingAction, setPendingAction] = useState<"chat" | null>(null);
|
|
|
|
const serverId = pendingWorkspaceSetup?.serverId ?? "";
|
|
const supportsForgeSearch = useSessionStore(
|
|
(state) => state.sessions[serverId]?.serverInfo?.features?.forgeSearch === true,
|
|
);
|
|
const sourceDirectory = pendingWorkspaceSetup?.sourceDirectory ?? "";
|
|
const displayName = pendingWorkspaceSetup?.displayName?.trim() ?? "";
|
|
const workspace = createdWorkspace;
|
|
const client = useHostRuntimeClient(serverId);
|
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
|
const chatDraft = useAgentInputDraft({
|
|
draftKey: `workspace-setup:${serverId}:${sourceDirectory}`,
|
|
composer: buildChatDraftComposerArgs({
|
|
serverId,
|
|
isConnected,
|
|
workspaceDirectory: workspace?.workspaceDirectory,
|
|
sourceDirectory,
|
|
pendingWorkspaceSetup,
|
|
}),
|
|
});
|
|
const composerState = chatDraft.composerState;
|
|
if (!composerState && pendingWorkspaceSetup) {
|
|
throw new Error(t("workspaceSetup.errors.composerStateRequired"));
|
|
}
|
|
|
|
const { icon: projectIcon } = useProjectIcon({
|
|
serverId,
|
|
cwd: sourceDirectory,
|
|
});
|
|
const iconDataUri = toProjectIconDataUri(projectIcon);
|
|
|
|
useEffect(() => {
|
|
setErrorMessage(null);
|
|
setCreatedWorkspace(null);
|
|
setPendingAction(null);
|
|
}, [pendingWorkspaceSetup?.creationMethod, serverId, sourceDirectory]);
|
|
|
|
const handleClose = useCallback(() => {
|
|
clearWorkspaceSetup();
|
|
}, [clearWorkspaceSetup]);
|
|
|
|
const navigateAfterCreation = useCallback(
|
|
(
|
|
workspaceId: string,
|
|
target: { kind: "agent"; agentId: string } | { kind: "terminal"; terminalId: string },
|
|
) => {
|
|
if (!pendingWorkspaceSetup) {
|
|
return;
|
|
}
|
|
|
|
clearWorkspaceSetup();
|
|
if (target.kind === "agent") {
|
|
navigateToAgent({
|
|
serverId: pendingWorkspaceSetup.serverId,
|
|
agentId: target.agentId,
|
|
});
|
|
return;
|
|
}
|
|
|
|
navigateToWorkspace({
|
|
serverId: pendingWorkspaceSetup.serverId,
|
|
workspaceId,
|
|
target,
|
|
});
|
|
},
|
|
[clearWorkspaceSetup, pendingWorkspaceSetup],
|
|
);
|
|
|
|
const withConnectedClient = useCallback(() => {
|
|
if (!client || !isConnected) {
|
|
throw new Error(t("workspaceSetup.errors.hostDisconnected"));
|
|
}
|
|
return client;
|
|
}, [client, isConnected, t]);
|
|
|
|
const ensureWorkspace = useCallback(
|
|
async (input: { cwd: string; attachments: MessagePayload["attachments"] }) => {
|
|
if (!pendingWorkspaceSetup) {
|
|
throw new Error(t("workspaceSetup.errors.pendingRequired"));
|
|
}
|
|
|
|
if (createdWorkspace) {
|
|
return createdWorkspace;
|
|
}
|
|
|
|
const connectedClient = withConnectedClient();
|
|
const payload = await callWorkspaceCreation({
|
|
creationMethod: pendingWorkspaceSetup.creationMethod,
|
|
connectedClient,
|
|
input,
|
|
});
|
|
|
|
if (payload.error || !payload.workspace) {
|
|
throw new Error(
|
|
payload.error ?? failureMessageForCreationMethod(pendingWorkspaceSetup.creationMethod, t),
|
|
);
|
|
}
|
|
|
|
const normalizedWorkspace = normalizeWorkspaceDescriptor(payload.workspace);
|
|
mergeWorkspaces(pendingWorkspaceSetup.serverId, [normalizedWorkspace]);
|
|
if (pendingWorkspaceSetup.creationMethod === "open_project") {
|
|
setHasHydratedWorkspaces(pendingWorkspaceSetup.serverId, true);
|
|
}
|
|
setCreatedWorkspace(normalizedWorkspace);
|
|
return normalizedWorkspace;
|
|
},
|
|
[
|
|
createdWorkspace,
|
|
mergeWorkspaces,
|
|
pendingWorkspaceSetup,
|
|
setHasHydratedWorkspaces,
|
|
t,
|
|
withConnectedClient,
|
|
],
|
|
);
|
|
|
|
const getIsStillActive = useCallback(() => {
|
|
const current = useWorkspaceSetupStore.getState().pendingWorkspaceSetup;
|
|
return (
|
|
current?.serverId === pendingWorkspaceSetup?.serverId &&
|
|
current?.sourceDirectory === pendingWorkspaceSetup?.sourceDirectory &&
|
|
current?.creationMethod === pendingWorkspaceSetup?.creationMethod
|
|
);
|
|
}, [
|
|
pendingWorkspaceSetup?.creationMethod,
|
|
pendingWorkspaceSetup?.serverId,
|
|
pendingWorkspaceSetup?.sourceDirectory,
|
|
]);
|
|
|
|
const handleCreateChatAgent = useCallback(
|
|
async ({ text, attachments, cwd }: MessagePayload) => {
|
|
try {
|
|
setPendingAction("chat");
|
|
setErrorMessage(null);
|
|
const ensuredWorkspace = await ensureWorkspace({ cwd, attachments });
|
|
const connectedClient = withConnectedClient();
|
|
if (!composerState) {
|
|
throw new Error(t("workspaceSetup.errors.composerStateRequired"));
|
|
}
|
|
if (!composerState.selectedProvider) {
|
|
throw new Error(t("workspaceSetup.errors.selectModel"));
|
|
}
|
|
|
|
const wirePayload = splitComposerAttachmentsForSubmit(attachments, {
|
|
format: resolveComposerAttachmentSubmitFormat({
|
|
supportsForgeAttachments: supportsForgeSearch,
|
|
}),
|
|
});
|
|
const encodedImages = await encodeImages(wirePayload.images);
|
|
const workspaceDirectory = requireWorkspaceDirectory({
|
|
workspaceId: ensuredWorkspace.id,
|
|
workspaceDirectory: ensuredWorkspace.workspaceDirectory,
|
|
});
|
|
const agent = await connectedClient.createAgent(
|
|
buildCreateAgentOptions({
|
|
composerState,
|
|
text,
|
|
attachments: wirePayload.attachments,
|
|
encodedImages: encodedImages ?? null,
|
|
workspaceDirectory,
|
|
workspaceId: ensuredWorkspace.id,
|
|
provider: composerState.selectedProvider,
|
|
}),
|
|
);
|
|
|
|
if (!getIsStillActive()) {
|
|
return;
|
|
}
|
|
|
|
setAgents(serverId, (previous) => {
|
|
const next = new Map(previous);
|
|
next.set(
|
|
agent.id,
|
|
applyLegacyDaemonWorkspaceOwnership({
|
|
serverId,
|
|
agent: normalizeAgentSnapshot(agent, serverId),
|
|
}),
|
|
);
|
|
return next;
|
|
});
|
|
navigateAfterCreation(ensuredWorkspace.id, { kind: "agent", agentId: agent.id });
|
|
} catch (error) {
|
|
const message = toErrorMessage(error);
|
|
setErrorMessage(message);
|
|
toast.error(message);
|
|
} finally {
|
|
if (getIsStillActive()) {
|
|
setPendingAction(null);
|
|
}
|
|
}
|
|
},
|
|
[
|
|
composerState,
|
|
getIsStillActive,
|
|
navigateAfterCreation,
|
|
serverId,
|
|
setAgents,
|
|
ensureWorkspace,
|
|
t,
|
|
toast,
|
|
withConnectedClient,
|
|
supportsForgeSearch,
|
|
],
|
|
);
|
|
|
|
const workspaceTitle = resolveWorkspaceTitle({ workspace, displayName, sourceDirectory });
|
|
|
|
const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(workspaceTitle);
|
|
const placeholderInitial = placeholderLabel.charAt(0).toUpperCase();
|
|
|
|
const agentControlsWithDisabled = useMemo(
|
|
() =>
|
|
composerState
|
|
? {
|
|
...composerState.agentControls,
|
|
disabled: pendingAction !== null,
|
|
}
|
|
: undefined,
|
|
[composerState, pendingAction],
|
|
);
|
|
|
|
const subtitleContent = useMemo(
|
|
() => (
|
|
<View style={styles.subtitleRow}>
|
|
<ProjectIconView
|
|
iconDataUri={iconDataUri}
|
|
initial={placeholderInitial}
|
|
projectViewKey={sourceDirectory}
|
|
size={ICON_SIZE.md}
|
|
textStyle={styles.projectIconFallbackText}
|
|
/>
|
|
<Text style={styles.projectTitle} numberOfLines={1}>
|
|
{workspaceTitle}
|
|
</Text>
|
|
</View>
|
|
),
|
|
[iconDataUri, placeholderInitial, sourceDirectory, workspaceTitle],
|
|
);
|
|
|
|
const sheetHeader = useMemo<SheetHeader>(
|
|
() => ({ title: t("workspaceSetup.title"), subtitle: subtitleContent }),
|
|
[subtitleContent, t],
|
|
);
|
|
|
|
if (!pendingWorkspaceSetup || !sourceDirectory) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<AdaptiveModalSheet
|
|
header={sheetHeader}
|
|
visible={true}
|
|
onClose={handleClose}
|
|
snapPoints={SNAP_POINTS}
|
|
testID="workspace-setup-dialog"
|
|
desktopMaxWidth={640}
|
|
>
|
|
<FileDropZone style={styles.section}>
|
|
<Composer
|
|
agentId={`workspace-setup:${serverId}:${sourceDirectory}`}
|
|
serverId={serverId}
|
|
isPaneFocused={true}
|
|
onSubmitMessage={handleCreateChatAgent}
|
|
isSubmitLoading={pendingAction === "chat"}
|
|
blurOnSubmit={true}
|
|
value={chatDraft.text}
|
|
onChangeText={chatDraft.editText}
|
|
textReplacementKey={chatDraft.textReplacementKey}
|
|
attachments={chatDraft.attachments}
|
|
onChangeAttachments={chatDraft.setAttachments}
|
|
cwd={sourceDirectory}
|
|
clearDraft={chatDraft.clear}
|
|
autoFocus
|
|
commandDraftConfig={composerState?.commandDraftConfig}
|
|
agentControls={agentControlsWithDisabled}
|
|
inputWrapperStyle={styles.composerInputWrapper}
|
|
/>
|
|
</FileDropZone>
|
|
|
|
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
|
</AdaptiveModalSheet>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create((theme) => ({
|
|
subtitleRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
},
|
|
projectIconFallbackText: {
|
|
fontSize: 9,
|
|
},
|
|
projectTitle: {
|
|
fontSize: theme.fontSize.base,
|
|
color: theme.colors.foregroundMuted,
|
|
},
|
|
section: {
|
|
gap: theme.spacing[3],
|
|
marginHorizontal: -theme.spacing[6],
|
|
marginVertical: -theme.spacing[2],
|
|
},
|
|
errorText: {
|
|
fontSize: theme.fontSize.base,
|
|
color: theme.colors.destructive,
|
|
lineHeight: 20,
|
|
},
|
|
composerInputWrapper: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
}));
|