Sidebar polish round. Menus. `dropdown-menu` and `context-menu` were two copies of one engine that differed only in how the trigger fires — identical from `computePosition` down — and neither could nest. Both are now thin wrappers over `components/ui/menu/`, which owns anchoring, a pure page-stack navigation model, and one presentation fork: flyout on pointer, pushed pages in a sheet on compact. All 31 call sites are unchanged. Items got a rounded inset fill, a visible separator, and `selected` (a check) split from `active` (a fill). Sidebar display preferences. The flat wall of four sections becomes one row per decision with its value, and per-feature toggles over what a workspace row shows: host, pull request, checks, scripts. Each is independent — a ticked toggle that draws nothing is a lie. `display-preferences/` hides that grouping lives in zustand while title source and row items are app settings. Timestamps. "Last activity" now ticks itself. The label rounds to the minute and never shows seconds; past a week it becomes a date. The timer lives in the leaf `<Text>`, at most one per resolution tier shared by every row, and a tick only re-renders when the string actually changed — so a memoized row that legitimately never re-renders still stays current. Colour. Full status tier regenerated at 90% of gamut max (was 85), same rule as the muted tier, lightness and hue untouched. File icons are pulled toward neutral in OKLab behind `getFileIconSvg` — material-icon-theme picks its palette for a tree where the icon is the only colour on the row, and ours is not. New/deleted files use the forge square-plus/square-minus glyphs at muted weight instead of tinted pills. Project icons round a little more; uploaded icons never do. The project status badge is a knockout again: it fills with the colour of the row behind it, so the ring reads as a gap in the icon rather than a white halo. File rows. The always-visible kebab is gone from the file explorer and diff tree in favour of right-click, which also reclaims the reserved action slot.
500 lines
16 KiB
TypeScript
500 lines
16 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { Image, 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 { 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";
|
|
import { projectIconRadius } from "@/components/project-icon-view";
|
|
|
|
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 iconSource = useMemo(() => (iconDataUri ? { uri: iconDataUri } : null), [iconDataUri]);
|
|
const agentControlsWithDisabled = useMemo(
|
|
() =>
|
|
composerState
|
|
? {
|
|
...composerState.agentControls,
|
|
disabled: pendingAction !== null,
|
|
}
|
|
: undefined,
|
|
[composerState, pendingAction],
|
|
);
|
|
|
|
const subtitleContent = useMemo(
|
|
() => (
|
|
<View style={styles.subtitleRow}>
|
|
{iconSource ? (
|
|
<Image source={iconSource} style={styles.projectIcon} />
|
|
) : (
|
|
<View style={styles.projectIconFallback}>
|
|
<Text style={styles.projectIconFallbackText}>{placeholderInitial}</Text>
|
|
</View>
|
|
)}
|
|
<Text style={styles.projectTitle} numberOfLines={1}>
|
|
{workspaceTitle}
|
|
</Text>
|
|
</View>
|
|
),
|
|
[iconSource, placeholderInitial, 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.setText}
|
|
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],
|
|
},
|
|
// A user's uploaded mark is sized, never clipped — see projectIconRadius.
|
|
projectIcon: {
|
|
width: theme.iconSize.md,
|
|
height: theme.iconSize.md,
|
|
},
|
|
projectIconFallback: {
|
|
width: theme.iconSize.md,
|
|
height: theme.iconSize.md,
|
|
borderRadius: projectIconRadius(theme.iconSize.md),
|
|
borderWidth: 1,
|
|
borderColor: theme.colors.border,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
projectIconFallbackText: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: 9,
|
|
},
|
|
projectTitle: {
|
|
fontSize: theme.fontSize.sm,
|
|
color: theme.colors.foregroundMuted,
|
|
},
|
|
section: {
|
|
gap: theme.spacing[3],
|
|
marginHorizontal: -theme.spacing[6],
|
|
marginVertical: -theme.spacing[2],
|
|
},
|
|
errorText: {
|
|
fontSize: theme.fontSize.sm,
|
|
color: theme.colors.destructive,
|
|
lineHeight: 20,
|
|
},
|
|
composerInputWrapper: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
}));
|