* Switch models from the Command Center Add a model switcher to the Command-K Command Center. Typing surfaces a flat, filterable list of "Model › Provider › Name" breadcrumb rows with provider icons: - Running agent: its own provider's models (a live agent can't change provider); selecting calls setAgentModel. - New draft tab: every available provider's models in one flat list; selecting sets provider + model on the draft via a focused-draft controller published to a global store (the draft form state is local to the composer subtree and otherwise unreachable from the global Command Center). Models only appear once the user starts typing, so the default palette view is unchanged. Reuses useProvidersSnapshot and the existing setAgentModel RPC — no protocol changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Update packages/app/src/components/command-center.tsx Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * Update packages/app/src/hooks/use-command-center.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * refactor(app): make Command Center extensible Let focused features register stable actions while the palette owns search, selection, and a single virtualized result projection. * fix(app): save model preference after agent switch Persist the shared model choice only after the daemon confirms the live agent switched successfully. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
827 lines
27 KiB
TypeScript
827 lines
27 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef } from "react";
|
|
import { Keyboard, ScrollView, Text, View } from "react-native";
|
|
import { useTranslation } from "react-i18next";
|
|
import ReanimatedAnimated from "react-native-reanimated";
|
|
import { StyleSheet } from "react-native-unistyles";
|
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
|
import { useContainerWidthBelow } from "@/hooks/use-container-width";
|
|
import invariant from "tiny-invariant";
|
|
import { Composer } from "@/composer";
|
|
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
|
|
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
|
|
import { ComposerImportPill } from "@/composer/draft/import-pill";
|
|
import { AgentStreamView } from "@/agent-stream/view";
|
|
import { composerWorkspaceAttachment } from "@/composer/attachments/workspace";
|
|
import { useAgentInputDraft } from "@/composer/draft/input-draft";
|
|
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
|
|
import { useDraftAgentCreateFlow, type DraftCreateAttempt } from "@/composer/draft/create-flow";
|
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
|
import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config";
|
|
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
|
import { usePanelStore } from "@/stores/panel-store";
|
|
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
|
import type { Agent } from "@/stores/session-store";
|
|
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
|
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
|
import { useCommandCenterActions } from "@/command-center/provider";
|
|
import { buildModelChoiceContributions } from "@/command-center/model-contributions";
|
|
import { getCommandCenterProviderIcon } from "@/command-center/provider-icon";
|
|
import { encodeImages } from "@/utils/encode-images";
|
|
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
|
|
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
|
|
import {
|
|
shouldAllowEmptyDraftText,
|
|
validateDraftSubmission,
|
|
} from "@/composer/draft/workspace-tab-core";
|
|
import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types";
|
|
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
|
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
|
import {
|
|
useDraftWorkspaceAttachmentScopeKey,
|
|
useWorkspaceAttachmentScopeKey,
|
|
useWorkspaceAttachmentsStore,
|
|
} from "@/attachments/workspace-attachments-store";
|
|
import type { UserMessageImageAttachment } from "@/types/stream";
|
|
import {
|
|
COMPACT_FORM_FACTOR_WIDTH,
|
|
MAX_CONTENT_WIDTH,
|
|
useIsCompactFormFactor,
|
|
} from "@/constants/layout";
|
|
import { isWeb } from "@/constants/platform";
|
|
import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store";
|
|
|
|
const EMPTY_PENDING_PERMISSIONS = new Map();
|
|
const EMPTY_ONLINE_SERVER_IDS: string[] = [];
|
|
const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
|
|
supportsStreaming: true,
|
|
supportsSessionPersistence: false,
|
|
supportsDynamicModes: false,
|
|
supportsMcpServers: false,
|
|
supportsReasoningStream: false,
|
|
supportsToolInvocations: false,
|
|
};
|
|
|
|
interface AutoSubmitConfig {
|
|
provider: string;
|
|
modeId: string | null;
|
|
model: string | null;
|
|
thinkingOptionId: string | null;
|
|
featureValues: Record<string, unknown>;
|
|
}
|
|
|
|
function resolveAutoSubmitConfig(
|
|
pending: {
|
|
provider: string;
|
|
modeId?: string | null;
|
|
model?: string | null;
|
|
thinkingOptionId?: string | null;
|
|
featureValues?: Record<string, unknown>;
|
|
} | null,
|
|
): AutoSubmitConfig | null {
|
|
if (!pending) return null;
|
|
return {
|
|
provider: pending.provider,
|
|
modeId: pending.modeId ?? null,
|
|
model: pending.model ?? null,
|
|
thinkingOptionId: pending.thinkingOptionId ?? null,
|
|
featureValues: pending.featureValues ?? {},
|
|
};
|
|
}
|
|
|
|
// Reconcile the form's selected mode against the currently discovered modes.
|
|
// The mode picker displays modeOptions[0] when the stored mode isn't in the
|
|
// list (e.g. a globally-remembered "plan" that this workspace's OpenCode config
|
|
// no longer defines), so the submitted mode must match that display — otherwise
|
|
// we'd send a stale mode the provider rejects while the UI showed a valid one.
|
|
function reconcileSelectedMode(modeOptionIds: readonly string[], selectedMode: string): string {
|
|
if (modeOptionIds.length === 0) {
|
|
return "";
|
|
}
|
|
return modeOptionIds.includes(selectedMode) ? selectedMode : (modeOptionIds[0] ?? "");
|
|
}
|
|
|
|
function resolveDraftModeIdOverride(input: {
|
|
autoSubmitConfig: AutoSubmitConfig | null;
|
|
modeOptionIds: readonly string[];
|
|
selectedMode: string;
|
|
}): { modeId: string } | Record<string, never> {
|
|
const { autoSubmitConfig, modeOptionIds, selectedMode } = input;
|
|
if (autoSubmitConfig?.modeId) {
|
|
return { modeId: autoSubmitConfig.modeId };
|
|
}
|
|
const reconciled = reconcileSelectedMode(modeOptionIds, selectedMode);
|
|
if (reconciled !== "") {
|
|
return { modeId: reconciled };
|
|
}
|
|
return {};
|
|
}
|
|
|
|
function resolveDraftModeId(input: {
|
|
autoSubmitConfig: AutoSubmitConfig | null;
|
|
modeOptionIds: readonly string[];
|
|
selectedMode: string;
|
|
}): string | null {
|
|
const { autoSubmitConfig, modeOptionIds, selectedMode } = input;
|
|
if (autoSubmitConfig?.modeId !== undefined) {
|
|
return autoSubmitConfig.modeId;
|
|
}
|
|
const reconciled = reconcileSelectedMode(modeOptionIds, selectedMode);
|
|
if (reconciled !== "") {
|
|
return reconciled;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function submitDraftCreateRequest(input: {
|
|
attempt: { clientMessageId: string };
|
|
text: string;
|
|
images?: UserMessageImageAttachment[];
|
|
attachments?: unknown;
|
|
cwd: string;
|
|
client: DaemonClient | null;
|
|
workspaceDirectory: string | null;
|
|
workspaceId: string | null;
|
|
autoSubmitConfig: AutoSubmitConfig | null;
|
|
composerState: {
|
|
selectedProvider: string | null;
|
|
selectedMode: string;
|
|
modeOptions: readonly { id: string }[];
|
|
effectiveModelId: string | null;
|
|
effectiveThinkingOptionId: string | null;
|
|
featureValues: Record<string, unknown> | undefined;
|
|
};
|
|
hostDisconnectedMessage: string;
|
|
selectModelMessage: string;
|
|
}): Promise<{ agentId: string | null; result: AgentSnapshotPayload }> {
|
|
const {
|
|
attempt,
|
|
text,
|
|
images,
|
|
attachments,
|
|
cwd,
|
|
client,
|
|
workspaceDirectory,
|
|
workspaceId,
|
|
autoSubmitConfig,
|
|
composerState,
|
|
} = input;
|
|
|
|
invariant(workspaceDirectory, "Workspace directory is required");
|
|
invariant(workspaceId, "Workspace id is required");
|
|
if (!client) {
|
|
throw new Error(input.hostDisconnectedMessage);
|
|
}
|
|
|
|
const provider = autoSubmitConfig?.provider ?? composerState.selectedProvider;
|
|
if (!provider) {
|
|
throw new Error(input.selectModelMessage);
|
|
}
|
|
const modeIdOverride = resolveDraftModeIdOverride({
|
|
autoSubmitConfig,
|
|
modeOptionIds: composerState.modeOptions.map((mode) => mode.id),
|
|
selectedMode: composerState.selectedMode,
|
|
});
|
|
const config = buildWorkspaceDraftAgentConfig({
|
|
provider,
|
|
cwd,
|
|
...modeIdOverride,
|
|
model: autoSubmitConfig?.model ?? (composerState.effectiveModelId || undefined),
|
|
thinkingOptionId:
|
|
autoSubmitConfig?.thinkingOptionId ?? (composerState.effectiveThinkingOptionId || undefined),
|
|
featureValues: autoSubmitConfig?.featureValues ?? composerState.featureValues,
|
|
});
|
|
|
|
const imagesData = await encodeImages(images);
|
|
const attachmentsArray = Array.isArray(attachments) ? attachments : undefined;
|
|
const result = await client.createAgent({
|
|
config,
|
|
workspaceId,
|
|
...(text ? { initialPrompt: text } : {}),
|
|
clientMessageId: attempt.clientMessageId,
|
|
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
|
|
...(attachmentsArray && attachmentsArray.length > 0 ? { attachments: attachmentsArray } : {}),
|
|
});
|
|
|
|
return {
|
|
agentId: result.id,
|
|
result,
|
|
};
|
|
}
|
|
|
|
function buildDraftAgentSnapshot(input: {
|
|
attempt: { timestamp: Date };
|
|
serverId: string;
|
|
tabId: string;
|
|
workspaceDirectory: string | null;
|
|
autoSubmitConfig: AutoSubmitConfig | null;
|
|
composerState: {
|
|
effectiveModelId: string | null;
|
|
effectiveThinkingOptionId: string | null;
|
|
modeOptions: readonly { id: string }[];
|
|
selectedMode: string;
|
|
selectedProvider: string | null;
|
|
agentControls: { features?: Agent["features"] };
|
|
};
|
|
selectModelMessage: string;
|
|
}): Agent {
|
|
const { attempt, serverId, tabId, workspaceDirectory, autoSubmitConfig, composerState } = input;
|
|
invariant(workspaceDirectory, "Workspace directory is required");
|
|
const now = attempt.timestamp;
|
|
const model = autoSubmitConfig?.model ?? (composerState.effectiveModelId || null);
|
|
const thinkingOptionId =
|
|
autoSubmitConfig?.thinkingOptionId ?? (composerState.effectiveThinkingOptionId || null);
|
|
const modeId = resolveDraftModeId({
|
|
autoSubmitConfig,
|
|
modeOptionIds: composerState.modeOptions.map((mode) => mode.id),
|
|
selectedMode: composerState.selectedMode,
|
|
});
|
|
const provider = autoSubmitConfig?.provider ?? composerState.selectedProvider;
|
|
if (!provider) {
|
|
throw new Error(input.selectModelMessage);
|
|
}
|
|
return {
|
|
serverId,
|
|
id: tabId,
|
|
provider,
|
|
status: "running",
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
lastUserMessageAt: now,
|
|
lastActivityAt: now,
|
|
capabilities: DRAFT_CAPABILITIES,
|
|
currentModeId: modeId,
|
|
availableModes: [],
|
|
pendingPermissions: [],
|
|
persistence: null,
|
|
runtimeInfo: { provider, sessionId: null, model, modeId },
|
|
title: "Agent",
|
|
cwd: workspaceDirectory,
|
|
model,
|
|
features: composerState.agentControls.features,
|
|
thinkingOptionId,
|
|
parentAgentId: null,
|
|
labels: {},
|
|
};
|
|
}
|
|
|
|
function buildDraftInitialValues(input: {
|
|
workingDir: string | null;
|
|
initialSetup: WorkspaceDraftTabSetup | null;
|
|
}): CreateAgentInitialValues | undefined {
|
|
if (!input.workingDir) {
|
|
return undefined;
|
|
}
|
|
if (!input.initialSetup) {
|
|
return { workingDir: input.workingDir };
|
|
}
|
|
return {
|
|
workingDir: input.workingDir,
|
|
provider: input.initialSetup.provider,
|
|
modeId: input.initialSetup.modeId,
|
|
model: input.initialSetup.model,
|
|
thinkingOptionId: input.initialSetup.thinkingOptionId,
|
|
};
|
|
}
|
|
|
|
function resolveDraftWorkingDirectory(input: {
|
|
workspaceDirectory: string | null;
|
|
initialSetup: WorkspaceDraftTabSetup | null;
|
|
}): string | null {
|
|
if (input.initialSetup) {
|
|
return input.initialSetup.cwd;
|
|
}
|
|
return input.workspaceDirectory;
|
|
}
|
|
|
|
function resolveOnlineServerIds(input: { isConnected: boolean; serverId: string }): string[] {
|
|
if (!input.isConnected) {
|
|
return EMPTY_ONLINE_SERVER_IDS;
|
|
}
|
|
return [input.serverId];
|
|
}
|
|
|
|
interface WorkspaceDraftAgentTabProps {
|
|
serverId: string;
|
|
workspaceId: string;
|
|
tabId: string;
|
|
draftId: string;
|
|
initialSetup?: WorkspaceDraftTabSetup;
|
|
isPaneFocused: boolean;
|
|
onCreated: (snapshot: AgentSnapshotPayload) => void;
|
|
onOpenWorkspaceFile: (request: WorkspaceFileOpenRequest) => void;
|
|
onOpenImportSheet?: () => void;
|
|
}
|
|
|
|
function resolveImportPillPress(
|
|
onOpenImportSheet: (() => void) | undefined,
|
|
isSubmitting: boolean,
|
|
): (() => void) | null {
|
|
if (isSubmitting) {
|
|
return null;
|
|
}
|
|
return onOpenImportSheet ?? null;
|
|
}
|
|
|
|
export function WorkspaceDraftAgentTab({
|
|
serverId,
|
|
workspaceId,
|
|
tabId,
|
|
draftId,
|
|
initialSetup = undefined,
|
|
isPaneFocused,
|
|
onCreated,
|
|
onOpenWorkspaceFile,
|
|
onOpenImportSheet,
|
|
}: WorkspaceDraftAgentTabProps) {
|
|
const { t } = useTranslation();
|
|
const insets = useSafeAreaInsets();
|
|
const client = useHostRuntimeClient(serverId);
|
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
|
const workspaceFields = useWorkspaceFields(serverId, workspaceId, (w) => ({
|
|
workspaceDirectory: w.workspaceDirectory,
|
|
id: w.id,
|
|
}));
|
|
const workspaceDirectory = workspaceFields?.workspaceDirectory || null;
|
|
const draftSetup = initialSetup ?? null;
|
|
const draftWorkingDirectory = resolveDraftWorkingDirectory({
|
|
workspaceDirectory,
|
|
initialSetup: draftSetup,
|
|
});
|
|
const draftInitialValues = buildDraftInitialValues({
|
|
workingDir: draftWorkingDirectory,
|
|
initialSetup: draftSetup,
|
|
});
|
|
const onlineServerIds = resolveOnlineServerIds({ isConnected, serverId });
|
|
const draftStoreKey = useMemo(
|
|
() =>
|
|
buildDraftStoreKey({
|
|
serverId,
|
|
agentId: tabId,
|
|
draftId,
|
|
}),
|
|
[draftId, serverId, tabId],
|
|
);
|
|
const draftInput = useAgentInputDraft({
|
|
draftKey: draftStoreKey,
|
|
composer: {
|
|
initialServerId: serverId,
|
|
initialValues: draftInitialValues,
|
|
initialFeatureValues: draftSetup?.featureValues,
|
|
isVisible: true,
|
|
onlineServerIds,
|
|
lockedWorkingDir: draftWorkingDirectory ?? undefined,
|
|
},
|
|
});
|
|
const composerState = draftInput.composerState;
|
|
if (!composerState) {
|
|
throw new Error("Workspace draft composer state is required");
|
|
}
|
|
|
|
const draftModelActions = useMemo(
|
|
() =>
|
|
buildModelChoiceContributions({
|
|
serverId,
|
|
providers: composerState.modelSelectorProviders,
|
|
selectedProvider: composerState.selectedProvider,
|
|
selectedModelId: composerState.effectiveModelId || null,
|
|
groupLabel: t("shell.commandCenter.modelGroupLabel"),
|
|
searchKeywords: t("shell.commandCenter.modelSearchKeywords"),
|
|
getIcon: getCommandCenterProviderIcon,
|
|
select: composerState.setProviderAndModelFromUser,
|
|
}),
|
|
[
|
|
composerState.effectiveModelId,
|
|
composerState.modelSelectorProviders,
|
|
composerState.selectedProvider,
|
|
composerState.setProviderAndModelFromUser,
|
|
serverId,
|
|
t,
|
|
],
|
|
);
|
|
const clearDraftInput = draftInput.clear;
|
|
const setDraftText = draftInput.setText;
|
|
const setDraftAttachments = draftInput.setAttachments;
|
|
const pendingAutoSubmit = useWorkspaceDraftSubmissionStore((state) => {
|
|
const pending = state.pendingByDraftId[draftId] ?? null;
|
|
return pending?.serverId === serverId && pending.workspaceId === workspaceId ? pending : null;
|
|
});
|
|
const pendingCreateAttempt = useCreateFlowStore((state) => {
|
|
const pending = state.pendingByDraftId[draftId] ?? null;
|
|
return pending?.serverId === serverId && pending.lifecycle === "active" ? pending : null;
|
|
});
|
|
const consumePendingAutoSubmit = useWorkspaceDraftSubmissionStore(
|
|
(state) => state.consumePending,
|
|
);
|
|
const autoSubmitConfig = resolveAutoSubmitConfig(pendingAutoSubmit);
|
|
const initialCreateAttempt = useMemo<DraftCreateAttempt | null>(() => {
|
|
if (!pendingAutoSubmit || !pendingCreateAttempt) {
|
|
return null;
|
|
}
|
|
if (pendingAutoSubmit.clientMessageId !== pendingCreateAttempt.clientMessageId) {
|
|
return null;
|
|
}
|
|
return {
|
|
clientMessageId: pendingCreateAttempt.clientMessageId,
|
|
text: pendingCreateAttempt.text,
|
|
timestamp: new Date(pendingCreateAttempt.timestamp),
|
|
...(pendingCreateAttempt.images && pendingCreateAttempt.images.length > 0
|
|
? { images: pendingCreateAttempt.images }
|
|
: {}),
|
|
...(pendingCreateAttempt.attachments && pendingCreateAttempt.attachments.length > 0
|
|
? { attachments: pendingCreateAttempt.attachments }
|
|
: {}),
|
|
};
|
|
}, [pendingAutoSubmit, pendingCreateAttempt]);
|
|
const allowsEmptyAutoSubmit = pendingAutoSubmit?.allowEmptyText === true;
|
|
const isCompactFormFactor = useIsCompactFormFactor();
|
|
const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow(
|
|
COMPACT_FORM_FACTOR_WIDTH,
|
|
{ initialIsBelow: isCompactFormFactor },
|
|
);
|
|
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
|
|
serverId,
|
|
cwd: composerState.workingDir,
|
|
workspaceId,
|
|
});
|
|
const draftAttachmentScopeKey = useDraftWorkspaceAttachmentScopeKey(draftId);
|
|
const attachmentScopeKeys = useMemo(
|
|
() => [draftAttachmentScopeKey, workspaceAttachmentScopeKey].filter(Boolean),
|
|
[draftAttachmentScopeKey, workspaceAttachmentScopeKey],
|
|
);
|
|
const clearWorkspaceAttachments = useWorkspaceAttachmentsStore(
|
|
(state) => state.clearWorkspaceAttachments,
|
|
);
|
|
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
|
|
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
|
const handleOpenWorkspaceAttachment = useCallback(
|
|
(attachment: WorkspaceComposerAttachment) => {
|
|
if (attachment.kind !== "review") {
|
|
return;
|
|
}
|
|
const checkout = {
|
|
serverId,
|
|
cwd: attachment.attachment.cwd,
|
|
isGit: true,
|
|
};
|
|
openFileExplorerForCheckout({
|
|
checkout,
|
|
isCompact: isCompactFormFactor,
|
|
});
|
|
setExplorerTabForCheckout({
|
|
...checkout,
|
|
tab: "changes",
|
|
});
|
|
},
|
|
[isCompactFormFactor, openFileExplorerForCheckout, serverId, setExplorerTabForCheckout],
|
|
);
|
|
|
|
const {
|
|
formErrorMessage,
|
|
isSubmitting,
|
|
optimisticStreamItems,
|
|
draftAgent,
|
|
handleCreateFromInput,
|
|
continueCreateFromAttempt,
|
|
} = useDraftAgentCreateFlow<Agent, AgentSnapshotPayload>({
|
|
draftId,
|
|
getPendingServerId: () => serverId,
|
|
initialAttempt: initialCreateAttempt,
|
|
allowEmptyText: allowsEmptyAutoSubmit,
|
|
validateBeforeSubmit: ({ text, attachments }) => {
|
|
const allowsEmptyDraftText = shouldAllowEmptyDraftText({
|
|
allowsEmptyAutoSubmit,
|
|
attachments,
|
|
});
|
|
return validateDraftSubmission({
|
|
text,
|
|
allowsEmptyAutoSubmit: allowsEmptyDraftText,
|
|
composerState,
|
|
autoSubmitConfig,
|
|
workspaceDirectory: draftWorkingDirectory,
|
|
hasClient: Boolean(client),
|
|
});
|
|
},
|
|
onBeforeSubmit: async () => {
|
|
await composerState.persistFormPreferences();
|
|
if (isWeb) {
|
|
(document.activeElement as HTMLElement | null)?.blur?.();
|
|
}
|
|
Keyboard.dismiss();
|
|
},
|
|
buildDraftAgent: (attempt) =>
|
|
buildDraftAgentSnapshot({
|
|
attempt,
|
|
serverId,
|
|
tabId,
|
|
workspaceDirectory: draftWorkingDirectory,
|
|
autoSubmitConfig,
|
|
composerState,
|
|
selectModelMessage: t("workspaceSetup.errors.selectModel"),
|
|
}),
|
|
createRequest: async ({ attempt, text, images, attachments, cwd }) =>
|
|
submitDraftCreateRequest({
|
|
attempt,
|
|
text,
|
|
images,
|
|
attachments,
|
|
cwd,
|
|
client,
|
|
workspaceDirectory: draftWorkingDirectory,
|
|
workspaceId: workspaceFields?.id ?? null,
|
|
autoSubmitConfig,
|
|
composerState,
|
|
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
|
|
selectModelMessage: t("workspaceSetup.errors.selectModel"),
|
|
}),
|
|
onCreateSuccess: ({ result }) => {
|
|
clearDraftInput("sent");
|
|
clearWorkspaceAttachments({ scopeKey: draftAttachmentScopeKey });
|
|
useWorkspaceDraftSubmissionStore.getState().clearDraftSetup({ draftId });
|
|
onCreated(result);
|
|
},
|
|
});
|
|
useCommandCenterActions({
|
|
sourceId: `draft:${serverId}:${tabId}`,
|
|
enabled: isPaneFocused && !isSubmitting,
|
|
actions: draftModelActions,
|
|
});
|
|
|
|
const isReadyForPendingAutoSubmit = Boolean(
|
|
pendingAutoSubmit &&
|
|
draftInput.isHydrated &&
|
|
draftWorkingDirectory &&
|
|
client &&
|
|
!composerState.isModelLoading,
|
|
);
|
|
const autoSubmitKeyRef = useRef<string | null>(null);
|
|
useEffect(() => {
|
|
if (!isReadyForPendingAutoSubmit) {
|
|
return;
|
|
}
|
|
const submitKey = `${serverId}:${workspaceId}:${draftId}`;
|
|
if (autoSubmitKeyRef.current === submitKey) {
|
|
return;
|
|
}
|
|
const submission = consumePendingAutoSubmit({ serverId, workspaceId, draftId });
|
|
if (!submission) {
|
|
return;
|
|
}
|
|
autoSubmitKeyRef.current = submitKey;
|
|
setDraftText("");
|
|
setDraftAttachments([]);
|
|
const preparedAttempt =
|
|
initialCreateAttempt?.clientMessageId === submission.clientMessageId
|
|
? initialCreateAttempt
|
|
: null;
|
|
const createPromise = preparedAttempt
|
|
? continueCreateFromAttempt({
|
|
attempt: preparedAttempt,
|
|
cwd: submission.cwd,
|
|
})
|
|
: handleCreateFromInput({
|
|
text: submission.text,
|
|
attachments: submission.attachments,
|
|
cwd: submission.cwd,
|
|
});
|
|
void createPromise.catch(() => {
|
|
setDraftText(submission.text);
|
|
setDraftAttachments(composerWorkspaceAttachment.userAttachmentsOnly(submission.attachments));
|
|
autoSubmitKeyRef.current = null;
|
|
});
|
|
}, [
|
|
continueCreateFromAttempt,
|
|
consumePendingAutoSubmit,
|
|
draftId,
|
|
handleCreateFromInput,
|
|
initialCreateAttempt,
|
|
isReadyForPendingAutoSubmit,
|
|
serverId,
|
|
setDraftAttachments,
|
|
setDraftText,
|
|
workspaceId,
|
|
]);
|
|
|
|
const focusInputRef = useRef<(() => void) | null>(null);
|
|
|
|
const handleFocusInputCallback = useCallback((focus: () => void) => {
|
|
focusInputRef.current = focus;
|
|
}, []);
|
|
|
|
const handleProviderSelectWithFocus = useCallback(
|
|
(provider: Parameters<typeof composerState.setProviderFromUser>[0]) => {
|
|
composerState.setProviderFromUser(provider);
|
|
focusInputRef.current?.();
|
|
},
|
|
[composerState],
|
|
);
|
|
|
|
const handleModeSelectWithFocus = useCallback(
|
|
(modeId: string) => {
|
|
composerState.setModeFromUser(modeId);
|
|
focusInputRef.current?.();
|
|
},
|
|
[composerState],
|
|
);
|
|
|
|
const handleModelSelectWithFocus = useCallback(
|
|
(modelId: string) => {
|
|
composerState.setModelFromUser(modelId);
|
|
focusInputRef.current?.();
|
|
},
|
|
[composerState],
|
|
);
|
|
|
|
const handleProviderAndModelSelectWithFocus = useCallback(
|
|
(
|
|
provider: Parameters<typeof composerState.setProviderAndModelFromUser>[0],
|
|
modelId: string,
|
|
) => {
|
|
composerState.setProviderAndModelFromUser(provider, modelId);
|
|
focusInputRef.current?.();
|
|
},
|
|
[composerState],
|
|
);
|
|
|
|
const handleThinkingOptionSelectWithFocus = useCallback(
|
|
(optionId: string) => {
|
|
composerState.setThinkingOptionFromUser(optionId);
|
|
focusInputRef.current?.();
|
|
},
|
|
[composerState],
|
|
);
|
|
|
|
const handleSetFeatureWithFocus = useCallback(
|
|
(featureId: string, value: unknown) => {
|
|
composerState.agentControls.onSetFeature?.(featureId, value);
|
|
focusInputRef.current?.();
|
|
},
|
|
[composerState],
|
|
);
|
|
|
|
const { style: composerKeyboardStyle } = useKeyboardShiftStyle({
|
|
mode: "translate",
|
|
});
|
|
|
|
const inputAreaWrapperStyle = useMemo(
|
|
() => [styles.inputAreaWrapper, { paddingBottom: insets.bottom }, composerKeyboardStyle],
|
|
[insets.bottom, composerKeyboardStyle],
|
|
);
|
|
|
|
const handleDropdownCloseFocus = useCallback(() => {
|
|
focusInputRef.current?.();
|
|
}, []);
|
|
const importPillPress = resolveImportPillPress(onOpenImportSheet, isSubmitting);
|
|
const composerAgentControls = useMemo(
|
|
() => ({
|
|
...composerState.agentControls,
|
|
onSelectProvider: handleProviderSelectWithFocus,
|
|
onSelectMode: handleModeSelectWithFocus,
|
|
onSelectModel: handleModelSelectWithFocus,
|
|
onSelectProviderAndModel: handleProviderAndModelSelectWithFocus,
|
|
onSelectThinkingOption: handleThinkingOptionSelectWithFocus,
|
|
onSetFeature: handleSetFeatureWithFocus,
|
|
onDropdownClose: handleDropdownCloseFocus,
|
|
disabled: isSubmitting,
|
|
}),
|
|
[
|
|
composerState.agentControls,
|
|
handleProviderSelectWithFocus,
|
|
handleModeSelectWithFocus,
|
|
handleModelSelectWithFocus,
|
|
handleProviderAndModelSelectWithFocus,
|
|
handleThinkingOptionSelectWithFocus,
|
|
handleSetFeatureWithFocus,
|
|
handleDropdownCloseFocus,
|
|
isSubmitting,
|
|
],
|
|
);
|
|
const composerFooter = useMemo(
|
|
() =>
|
|
isCompactComposerLayout ? (
|
|
<DraftAgentModeControl
|
|
placement="footer"
|
|
{...composerAgentControls}
|
|
isCompactLayout={isCompactComposerLayout}
|
|
/>
|
|
) : undefined,
|
|
[isCompactComposerLayout, composerAgentControls],
|
|
);
|
|
|
|
return (
|
|
<FileDropZone style={styles.container}>
|
|
<View style={styles.contentContainer}>
|
|
{isSubmitting && draftAgent ? (
|
|
<View style={styles.streamContainer}>
|
|
<AgentStreamView
|
|
agentId={tabId}
|
|
serverId={serverId}
|
|
context={draftAgent}
|
|
streamItems={optimisticStreamItems}
|
|
pendingPermissions={EMPTY_PENDING_PERMISSIONS}
|
|
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
|
/>
|
|
</View>
|
|
) : (
|
|
<ScrollView style={styles.scrollView} contentContainerStyle={styles.configScrollContent}>
|
|
<View style={styles.configSection}>
|
|
{formErrorMessage ? (
|
|
<View style={styles.errorContainer}>
|
|
<Text style={styles.errorText}>{formErrorMessage}</Text>
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
</ScrollView>
|
|
)}
|
|
</View>
|
|
|
|
<ReanimatedAnimated.View style={inputAreaWrapperStyle} onLayout={onInputAreaLayout}>
|
|
{importPillPress ? (
|
|
<View style={styles.importPillRow}>
|
|
<View style={styles.importPillContent}>
|
|
<ComposerImportPill onPress={importPillPress} />
|
|
</View>
|
|
</View>
|
|
) : null}
|
|
<Composer
|
|
agentId={tabId}
|
|
serverId={serverId}
|
|
externalKeyboardShift
|
|
isPaneFocused={isPaneFocused}
|
|
onSubmitMessage={handleCreateFromInput}
|
|
isSubmitLoading={isSubmitting}
|
|
blurOnSubmit={true}
|
|
value={draftInput.text}
|
|
onChangeText={draftInput.setText}
|
|
attachments={draftInput.attachments}
|
|
attachmentScopeKeys={attachmentScopeKeys}
|
|
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
|
|
onChangeAttachments={draftInput.setAttachments}
|
|
cwd={composerState.workingDir}
|
|
clearDraft={draftInput.clear}
|
|
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
|
|
onFocusInput={handleFocusInputCallback}
|
|
commandDraftConfig={composerState.commandDraftConfig}
|
|
agentControls={composerAgentControls}
|
|
footer={composerFooter}
|
|
isCompactLayout={isCompactComposerLayout}
|
|
/>
|
|
</ReanimatedAnimated.View>
|
|
</FileDropZone>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create((theme) => ({
|
|
container: {
|
|
flex: 1,
|
|
width: "100%",
|
|
backgroundColor: theme.colors.surface0,
|
|
},
|
|
contentContainer: {
|
|
flex: 1,
|
|
},
|
|
streamContainer: {
|
|
flex: 1,
|
|
},
|
|
scrollView: {
|
|
flex: 1,
|
|
},
|
|
configScrollContent: {
|
|
paddingHorizontal: theme.spacing[4],
|
|
paddingTop: theme.spacing[4],
|
|
paddingBottom: theme.spacing[6],
|
|
},
|
|
configSection: {
|
|
gap: theme.spacing[3],
|
|
},
|
|
inputAreaWrapper: {
|
|
width: "100%",
|
|
backgroundColor: theme.colors.surface0,
|
|
},
|
|
importPillRow: {
|
|
width: "100%",
|
|
paddingHorizontal: theme.spacing[4],
|
|
paddingTop: theme.spacing[3],
|
|
paddingBottom: theme.spacing[3],
|
|
alignItems: "center",
|
|
},
|
|
importPillContent: {
|
|
width: "100%",
|
|
maxWidth: MAX_CONTENT_WIDTH,
|
|
flexDirection: "row",
|
|
},
|
|
errorContainer: {
|
|
marginTop: theme.spacing[2],
|
|
paddingHorizontal: theme.spacing[3],
|
|
paddingVertical: theme.spacing[2],
|
|
borderRadius: theme.borderRadius.md,
|
|
backgroundColor: theme.colors.surface2,
|
|
borderWidth: 1,
|
|
borderColor: theme.colors.destructive,
|
|
},
|
|
errorText: {
|
|
color: theme.colors.destructive,
|
|
},
|
|
}));
|