Merge branch 'main' of github.com:getpaseo/paseo

This commit is contained in:
Mohamed Boudra
2026-06-29 20:24:57 +02:00
156 changed files with 5976 additions and 1189 deletions
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import {
getWorkspaceNamingAttachments,
remapDraftCwdToWorkspace,
} from "./new-workspace-fork-context";
describe("remapDraftCwdToWorkspace", () => {
it("preserves a Windows subdirectory when source path casing differs", () => {
expect(
remapDraftCwdToWorkspace({
cwd: "c:\\Repo\\packages\\app",
sourceDirectory: "C:\\Repo",
workspaceDirectory: "D:\\Worktrees\\fork",
}),
).toBe("D:\\Worktrees\\fork\\packages\\app");
});
it("falls back to the workspace root when the cwd is outside the source directory", () => {
expect(
remapDraftCwdToWorkspace({
cwd: "/other/repo/packages/app",
sourceDirectory: "/repo",
workspaceDirectory: "/worktrees/fork",
}),
).toBe("/worktrees/fork");
});
});
describe("getWorkspaceNamingAttachments", () => {
it("removes full chat history from workspace naming context", () => {
const chatHistory = {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
title: "Chat history",
text: "Long prior conversation",
} satisfies AgentAttachment;
const prContext = {
type: "github_pr",
mimeType: "application/github-pr",
number: 1788,
title: "Fork assistant turns into new drafts",
url: "https://github.com/getpaseo/paseo/pull/1788",
} satisfies AgentAttachment;
expect(getWorkspaceNamingAttachments([chatHistory, prContext])).toEqual([prContext]);
});
});
@@ -0,0 +1,50 @@
import type { AgentAttachment } from "@getpaseo/protocol/messages";
function isLikelyWindowsPath(path: string): boolean {
return /^[a-zA-Z]:\//.test(path);
}
function isChatHistoryTextAttachment(attachment: AgentAttachment): boolean {
return attachment.type === "text" && attachment.contextKind === "chat_history";
}
export function getWorkspaceNamingAttachments(
attachments: readonly AgentAttachment[],
): AgentAttachment[] {
return attachments.filter((attachment) => !isChatHistoryTextAttachment(attachment));
}
export function remapDraftCwdToWorkspace(input: {
cwd: string;
sourceDirectory?: string | null;
workspaceDirectory: string;
}): string {
const cwd = input.cwd.trim();
const sourceDirectory = input.sourceDirectory?.trim();
const workspaceDirectory = input.workspaceDirectory.trim();
if (!cwd || !sourceDirectory) {
return workspaceDirectory;
}
const normalizedCwd = cwd.replace(/\\/g, "/").replace(/\/+$/, "");
const normalizedSource = sourceDirectory.replace(/\\/g, "/").replace(/\/+$/, "");
const compareCaseInsensitively =
isLikelyWindowsPath(normalizedCwd) || isLikelyWindowsPath(normalizedSource);
const comparableCwd = compareCaseInsensitively ? normalizedCwd.toLowerCase() : normalizedCwd;
const comparableSource = compareCaseInsensitively
? normalizedSource.toLowerCase()
: normalizedSource;
if (comparableCwd === comparableSource) {
return workspaceDirectory;
}
const relativePath = comparableCwd.startsWith(`${comparableSource}/`)
? normalizedCwd.slice(normalizedSource.length + 1)
: "";
if (!relativePath) {
return workspaceDirectory;
}
const separator =
workspaceDirectory.includes("\\") && !workspaceDirectory.includes("/") ? "\\" : "/";
return [workspaceDirectory.replace(/[\\/]+$/, ""), ...relativePath.split("/")]
.filter(Boolean)
.join(separator);
}
+225 -22
View File
@@ -11,6 +11,7 @@ import { createNameId } from "mnemonic-id";
import { useQuery } from "@tanstack/react-query";
import { Check, ChevronDown, Folder, GitBranch, GitPullRequest, X } from "lucide-react-native";
import { Composer } from "@/composer";
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
import { HostStatusDot } from "@/components/host-status-dot";
@@ -35,10 +36,14 @@ import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-
import { useWorkspace } from "@/stores/session-store-hooks";
import { generateDraftId } from "@/stores/draft-keys";
import { useDraftStore } from "@/stores/draft-store";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store";
import {
useWorkspaceDraftSubmissionStore,
type PendingWorkspaceDraftSetup,
} from "@/stores/workspace-draft-submission-store";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useFormPreferences } from "@/hooks/use-form-preferences";
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
import { generateMessageId } from "@/types/stream";
import { toErrorMessage } from "@/utils/error-messages";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
@@ -56,11 +61,17 @@ import {
} from "@/projects/host-projects";
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types";
import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
import type { MessagePayload } from "@/composer/types";
import type { AgentAttachment, GitHubSearchItem } from "@getpaseo/protocol/messages";
import type { CreatePaseoWorktreeInput } from "@getpaseo/client/internal/daemon-client";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty";
import {
getWorkspaceNamingAttachments,
remapDraftCwdToWorkspace,
} from "./new-workspace-fork-context";
import {
pickerItemToCheckoutRequest,
type PickerCheckoutRequest,
@@ -81,6 +92,37 @@ function resolveCheckoutRequest(
};
}
function useIsNewWorkspaceDraftHandoffActive(input: {
draftId: string | undefined;
selectedServerId: string;
}): boolean {
const normalizedDraftId = input.draftId?.trim() ?? "";
return useCreateFlowStore((state) =>
isActiveCreateFlowForDraft({
draftId: normalizedDraftId,
serverId: input.selectedServerId,
pending: normalizedDraftId ? state.pendingByDraftId[normalizedDraftId] : null,
}),
);
}
function resolveVisibleDraftContextScopeKeys(input: {
isDraftHandoffActive: boolean;
draftContextScopeKey: string;
}): readonly string[] {
if (input.isDraftHandoffActive || !input.draftContextScopeKey) {
return [];
}
return [input.draftContextScopeKey];
}
function isNewWorkspacePending(input: {
pendingAction: "chat" | "empty" | null;
isDraftHandoffActive: boolean;
}): boolean {
return input.pendingAction !== null || input.isDraftHandoffActive;
}
function buildFirstAgentContext(input: {
prompt: string;
attachments: AgentAttachment[];
@@ -101,6 +143,7 @@ interface NewWorkspaceScreenProps {
sourceDirectory?: string;
projectId?: string;
displayName?: string;
draftId?: string;
}
interface PickerOptionData {
@@ -713,6 +756,18 @@ function getContentStyle(input: { isCompact: boolean; insetBottom: number }) {
return [styles.content, styles.contentCentered];
}
function buildNewWorkspaceDraftKey(input: {
selectedServerId: string;
selectedSourceDirectory: string | null;
draftId?: string;
}): string {
const explicitDraftId = input.draftId?.trim();
if (explicitDraftId) {
return `new-workspace:draft:${explicitDraftId}`;
}
return `new-workspace:${input.selectedServerId}:${input.selectedSourceDirectory ?? "choose-project"}`;
}
function getSelectedPickerItem(selection: PickerSelection | null): PickerItem | null {
if (!selection) return null;
return selection.item;
@@ -732,12 +787,28 @@ function normalizeBranchDetails(
interface SubmitDraftInput {
serverId: string;
draftKey: string;
draftId?: string;
initialSetup?: WorkspaceDraftTabSetup;
workspaceId: string;
workspaceDirectory: string;
text: string;
attachments: ComposerAttachment[];
provider: AgentProvider;
composerState: NonNullable<ReturnType<typeof useAgentInputDraft>["composerState"]>;
composerState: NewWorkspaceComposerState;
}
type NewWorkspaceComposerState = NonNullable<
ReturnType<typeof useAgentInputDraft>["composerState"]
>;
interface WorkspaceDraftSubmissionConfig {
cwd: string;
provider: AgentProvider;
modeId: string | null;
model: string | null;
thinkingOptionId: string | null;
featureValues: Record<string, unknown> | undefined;
target: WorkspaceTabTarget;
}
async function createAndMergeWorkspace(input: {
@@ -819,6 +890,7 @@ async function createMultiplicityWorkspace(input: {
interface CreateChatAgentInput {
payload: MessagePayload;
composerState: ReturnType<typeof useAgentInputDraft>["composerState"];
forkDraftSetup?: PendingWorkspaceDraftSetup | null;
ensureWorkspace: (input: {
cwd: string;
prompt: string;
@@ -827,12 +899,67 @@ interface CreateChatAgentInput {
}) => Promise<ReturnType<typeof normalizeWorkspaceDescriptor>>;
serverId: string;
draftKey: string;
draftId?: string;
labels: {
composerStateRequired: string;
selectModel: string;
};
}
function buildWorkspaceDraftSetupFromComposer(input: {
cwd: string;
provider: AgentProvider;
composerState: NewWorkspaceComposerState;
}): WorkspaceDraftTabSetup {
return {
provider: input.provider,
cwd: input.cwd,
modeId: input.composerState.selectedMode || null,
model: input.composerState.effectiveModelId || null,
thinkingOptionId: input.composerState.effectiveThinkingOptionId || null,
featureValues: input.composerState.featureValues ?? {},
};
}
function buildWorkspaceDraftSetupForCreatedWorkspace(input: {
forkDraftSetup: PendingWorkspaceDraftSetup | null | undefined;
workspaceDirectory: string;
provider: AgentProvider;
composerState: NewWorkspaceComposerState;
}): WorkspaceDraftTabSetup | undefined {
if (!input.forkDraftSetup) {
return undefined;
}
return buildWorkspaceDraftSetupFromComposer({
cwd: remapDraftCwdToWorkspace({
cwd: input.forkDraftSetup.setup.cwd,
sourceDirectory: input.forkDraftSetup.sourceDirectory,
workspaceDirectory: input.workspaceDirectory,
}),
provider: input.provider,
composerState: input.composerState,
});
}
function buildComposerInitialValues(input: {
workingDir: string | undefined;
initialSetup?: WorkspaceDraftTabSetup | null;
}): CreateAgentInitialValues | undefined {
if (input.initialSetup) {
return {
workingDir: input.workingDir ?? input.initialSetup.cwd,
provider: input.initialSetup.provider,
modeId: input.initialSetup.modeId,
model: input.initialSetup.model,
thinkingOptionId: input.initialSetup.thinkingOptionId,
};
}
if (input.workingDir) {
return { workingDir: input.workingDir };
}
return undefined;
}
async function runCreateChatAgent(input: CreateChatAgentInput): Promise<void> {
const { payload, composerState, ensureWorkspace, serverId, draftKey } = input;
const { text, attachments, cwd } = payload;
@@ -844,15 +971,24 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise<void> {
throw new Error(input.labels.selectModel);
}
const { attachments: reviewAttachments } = splitComposerAttachmentsForSubmit(attachments);
const workspaceNamingAttachments = getWorkspaceNamingAttachments(reviewAttachments);
const ensuredWorkspace = await ensureWorkspace({
cwd,
prompt: text,
attachments: reviewAttachments,
attachments: workspaceNamingAttachments,
withInitialAgent: true,
});
const initialSetup = buildWorkspaceDraftSetupForCreatedWorkspace({
forkDraftSetup: input.forkDraftSetup,
workspaceDirectory: ensuredWorkspace.workspaceDirectory,
provider,
composerState,
});
submitWorkspaceDraft({
serverId,
draftKey,
draftId: input.draftId,
initialSetup,
workspaceId: ensuredWorkspace.id,
workspaceDirectory: ensuredWorkspace.workspaceDirectory,
text,
@@ -867,12 +1003,14 @@ function buildComposerConfig(input: {
isConnected: boolean;
workspaceDirectory: string | null;
sourceDirectory: string | null;
initialSetup?: WorkspaceDraftTabSetup | null;
}): Parameters<typeof useAgentInputDraft>[0]["composer"] {
const { serverId, isConnected, workspaceDirectory, sourceDirectory } = input;
const { serverId, isConnected, workspaceDirectory, sourceDirectory, initialSetup } = input;
const workingDir = workspaceDirectory || sourceDirectory || undefined;
return {
initialServerId: serverId || null,
initialValues: workingDir ? { workingDir } : undefined,
initialValues: buildComposerInitialValues({ workingDir, initialSetup }),
initialFeatureValues: initialSetup?.featureValues,
isVisible: true,
onlineServerIds: isConnected && serverId ? [serverId] : [],
lockedWorkingDir: workingDir,
@@ -920,21 +1058,72 @@ function useCheckoutHintDismissals(attachments: ReadonlyArray<UserComposerAttach
return [dismissedPrNumbers, setDismissedPrNumbers] as const;
}
function usePendingWorkspaceDraftSetup(
draftId: string | undefined,
): PendingWorkspaceDraftSetup | null {
const normalizedDraftId = draftId?.trim() ?? "";
return useWorkspaceDraftSubmissionStore((state) => {
if (!normalizedDraftId) {
return null;
}
return state.setupByDraftId[normalizedDraftId] ?? null;
});
}
function resolveWorkspaceDraftSubmissionConfig(input: {
draftId: string;
workspaceDirectory: string;
provider: AgentProvider;
composerState: NewWorkspaceComposerState;
initialSetup?: WorkspaceDraftTabSetup;
}): WorkspaceDraftSubmissionConfig {
const { draftId, workspaceDirectory, provider, composerState, initialSetup } = input;
if (initialSetup) {
return {
cwd: initialSetup.cwd,
provider: initialSetup.provider,
modeId: initialSetup.modeId,
model: initialSetup.model,
thinkingOptionId: initialSetup.thinkingOptionId,
featureValues: initialSetup.featureValues,
target: { kind: "draft", draftId, setup: initialSetup },
};
}
return {
cwd: workspaceDirectory,
provider,
modeId: composerState.selectedMode || null,
model: composerState.effectiveModelId || null,
thinkingOptionId: composerState.effectiveThinkingOptionId || null,
featureValues: composerState.featureValues,
target: { kind: "draft", draftId },
};
}
function submitWorkspaceDraft(input: SubmitDraftInput): void {
const {
serverId,
draftKey,
draftId: draftIdInput,
workspaceId,
workspaceDirectory,
text,
attachments,
provider,
composerState,
initialSetup,
} = input;
const draftId = generateDraftId();
const draftId = draftIdInput?.trim() || generateDraftId();
const clientMessageId = generateMessageId();
const timestamp = Date.now();
const wirePayload = splitComposerAttachmentsForSubmit(attachments);
const submission = resolveWorkspaceDraftSubmissionConfig({
draftId,
workspaceDirectory,
provider,
composerState,
initialSetup,
});
useCreateFlowStore.getState().setPending({
serverId,
draftId,
@@ -952,23 +1141,21 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void {
draftId,
text: text.trim(),
attachments,
cwd: workspaceDirectory,
provider,
cwd: submission.cwd,
provider: submission.provider,
clientMessageId,
timestamp,
...(composerState.selectedMode !== "" ? { modeId: composerState.selectedMode } : {}),
...(composerState.effectiveModelId ? { model: composerState.effectiveModelId } : {}),
...(composerState.effectiveThinkingOptionId
? { thinkingOptionId: composerState.effectiveThinkingOptionId }
: {}),
...(composerState.featureValues ? { featureValues: composerState.featureValues } : {}),
...(submission.modeId ? { modeId: submission.modeId } : {}),
...(submission.model ? { model: submission.model } : {}),
...(submission.thinkingOptionId ? { thinkingOptionId: submission.thinkingOptionId } : {}),
...(submission.featureValues ? { featureValues: submission.featureValues } : {}),
allowEmptyText: true,
});
navigateToPreparedWorkspaceTab({
serverId,
workspaceId,
currentPathname: "/new",
target: { kind: "draft", draftId },
target: submission.target,
});
useDraftStore.getState().clearDraftInput({ draftKey, lifecycle: "sent" });
}
@@ -1359,6 +1546,7 @@ export function NewWorkspaceScreen({
sourceDirectory: sourceDirectoryProp,
projectId,
displayName: displayNameProp,
draftId,
}: NewWorkspaceScreenProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
@@ -1395,6 +1583,7 @@ export function NewWorkspaceScreen({
const projectPickerAnchorRef = useRef<View>(null);
const isolationPickerAnchorRef = useRef<View>(null);
const hostPickerAnchorRef = useRef<View | null>(null);
const isDraftHandoffActive = useIsNewWorkspaceDraftHandoffActive({ draftId, selectedServerId });
useEffect(() => {
const trimmed = pickerSearchQuery.trim();
@@ -1403,7 +1592,7 @@ export function NewWorkspaceScreen({
}, [pickerSearchQuery]);
const workspace = createdWorkspace;
const isPending = pendingAction !== null;
const isPending = isNewWorkspacePending({ pendingAction, isDraftHandoffActive });
const client = useHostRuntimeClient(selectedServerId);
const isConnected = useHostRuntimeIsConnected(selectedServerId);
const {
@@ -1440,7 +1629,17 @@ export function NewWorkspaceScreen({
const projectIconDataByProjectKey = useProjectIconDataByProjectKey({
projects: projectIconTargets,
});
const draftKey = `new-workspace:${selectedServerId}:${selectedSourceDirectory ?? "choose-project"}`;
const draftKey = buildNewWorkspaceDraftKey({
selectedServerId,
selectedSourceDirectory,
draftId,
});
const forkDraftSetup = usePendingWorkspaceDraftSetup(draftId);
const draftContextScopeKey = useDraftWorkspaceAttachmentScopeKey(draftId);
const visibleDraftContextScopeKeys = useMemo(
() => resolveVisibleDraftContextScopeKeys({ isDraftHandoffActive, draftContextScopeKey }),
[draftContextScopeKey, isDraftHandoffActive],
);
const chatDraft = useAgentInputDraft({
draftKey,
composer: buildComposerConfig({
@@ -1448,6 +1647,7 @@ export function NewWorkspaceScreen({
isConnected,
workspaceDirectory: workspace?.workspaceDirectory ?? null,
sourceDirectory: selectedSourceDirectory,
initialSetup: forkDraftSetup?.setup,
}),
});
const composerState = chatDraft.composerState;
@@ -1792,9 +1992,11 @@ export function NewWorkspaceScreen({
await runCreateChatAgent({
payload,
composerState,
forkDraftSetup,
ensureWorkspace,
serverId: selectedServerId,
draftKey,
draftId,
labels: {
composerStateRequired: t("newWorkspace.errors.composerStateRequired"),
selectModel: t("newWorkspace.errors.selectModel"),
@@ -1807,7 +2009,7 @@ export function NewWorkspaceScreen({
toast.error(message);
}
},
[composerState, draftKey, ensureWorkspace, selectedServerId, t, toast],
[composerState, draftId, draftKey, ensureWorkspace, forkDraftSetup, selectedServerId, t, toast],
);
const renderPickerOption = useCallback(
@@ -1967,7 +2169,7 @@ export function NewWorkspaceScreen({
const screenHeaderLeft = useMemo(() => <SidebarMenuToggle />, []);
return (
<View style={styles.container}>
<FileDropZone style={styles.container}>
<ScreenHeader left={screenHeaderLeft} borderless />
<View style={contentStyle}>
<TitlebarDragRegion />
@@ -1986,12 +2188,13 @@ export function NewWorkspaceScreen({
submitButtonAccessibilityLabel={t("newWorkspace.create")}
submitButtonTestID="workspace-create-submit"
submitIcon="return"
isSubmitLoading={pendingAction !== null}
isSubmitLoading={isPending}
submitBehavior="preserve-and-lock"
blurOnSubmit={true}
value={chatDraft.text}
onChangeText={chatDraft.setText}
attachments={chatDraft.attachments}
attachmentScopeKeys={visibleDraftContextScopeKeys}
onChangeAttachments={chatDraft.setAttachments}
cwd={selectedSourceDirectory ?? ""}
clearDraft={handleClearDraft}
@@ -2003,7 +2206,7 @@ export function NewWorkspaceScreen({
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</ReanimatedAnimated.View>
</View>
</View>
</FileDropZone>
);
}
@@ -1244,7 +1244,7 @@ function RemoveHostSection({
await updateSettings({ daemon: { manageBuiltInDaemon: false } });
didDisableDaemonManagement = true;
if (daemonStatus?.status === "running" && daemonStatus.desktopManaged) {
setStatus(await stopDesktopDaemon());
setStatus(await stopDesktopDaemon("host_remove"));
didStopDaemon = true;
}
await removeHost(host.serverId);