mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 17:04:42 +00:00
The agent snapshot's `options` are the state and `controls` only draw them. Running and closed agents draw from the snapshot; drafts and profile forms ask `provider.controls.list.request` with their `providerOptions`, so aliases such as a legacy Fast profile's `service_tier: "fast"` are resolved once, by the daemon, and the draft keeps Fast highlighted. A draft adds only the user's edits and each provider's last-used control values; applying a profile seeds its non-control options (e.g. `sandbox_mode`) into the draft, and a profile form saves them back instead of pruning to the controls it can draw. A daemon without `server_info.features.providerOptionControls` still sends `features` and `featureValues`. `LegacyProviderOptions` in the client package is the one place that translates snapshots, draft listings, create and apply requests, and daemon-config profiles, so the app never branches on it. Persisted preferences and tab layouts rename `featureValues` through tagged COMPAT preprocessors. The features/controls bridge moves from the server into the protocol package so the client can reuse it.
194 lines
6.4 KiB
TypeScript
194 lines
6.4 KiB
TypeScript
import { useRouter } from "expo-router";
|
|
import { useTranslation } from "react-i18next";
|
|
import type {
|
|
AgentForkContextOptions,
|
|
DaemonClient,
|
|
} from "@getpaseo/client/internal/daemon-client";
|
|
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
|
import type { AssistantForkTarget } from "@/components/assistant-fork-menu";
|
|
import type { ToastApi } from "@/components/toast-host";
|
|
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
|
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
|
import { useHostFeature } from "@/runtime/host-features";
|
|
import { generateDraftId } from "@/stores/draft-keys";
|
|
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
|
import { useSessionStore } from "@/stores/session-store";
|
|
import {
|
|
buildDraftWorkspaceAttachmentScopeKey,
|
|
useWorkspaceAttachmentsStore,
|
|
} from "@/attachments/workspace-attachments-store";
|
|
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
|
import { toErrorMessage } from "@/utils/error-messages";
|
|
import { buildNewWorkspaceRoute } from "@/utils/host-routes";
|
|
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model";
|
|
|
|
/**
|
|
* The subset of an agent record that a fork needs in order to seed the new
|
|
* draft. Kept structural so both `AgentScreenAgent` (the agent-stream view's
|
|
* live context) and the session store's `Agent` record satisfy it without a
|
|
* projection step.
|
|
*/
|
|
export type ForkAgentSource = Pick<
|
|
AgentScreenAgent,
|
|
| "provider"
|
|
| "cwd"
|
|
| "currentModeId"
|
|
| "model"
|
|
| "thinkingOptionId"
|
|
| "runtimeInfo"
|
|
| "options"
|
|
| "projectPlacement"
|
|
>;
|
|
|
|
/**
|
|
* Boundary marking where the forked context should stop. Omit it entirely to
|
|
* fork the whole timeline *up to now* — including a partially streamed
|
|
* in-flight turn. `selectForkContextRows` projects the full timeline when
|
|
* neither field is present, which is what makes mid-run forking work.
|
|
*/
|
|
export type ForkAgentBoundary = Pick<
|
|
AgentForkContextOptions,
|
|
"boundaryCursor" | "boundaryMessageId"
|
|
>;
|
|
|
|
export interface ForkAgentRequest {
|
|
agentId: string;
|
|
agent: ForkAgentSource;
|
|
workspaceId?: string;
|
|
target: AssistantForkTarget;
|
|
boundary?: ForkAgentBoundary;
|
|
}
|
|
|
|
export interface UseForkAgentInput {
|
|
serverId: string;
|
|
toast?: ToastApi | null;
|
|
/** Read-only surfaces (provider subagent panes) must never fork. */
|
|
readOnly?: boolean;
|
|
}
|
|
|
|
function buildChatHistoryAttachment(input: {
|
|
draftId: string;
|
|
serverId: string;
|
|
agentId: string;
|
|
payload: Awaited<ReturnType<DaemonClient["buildAgentForkContext"]>>;
|
|
missingAttachmentMessage: string;
|
|
}): WorkspaceComposerAttachment {
|
|
if (!input.payload.attachment) {
|
|
throw new Error(input.missingAttachmentMessage);
|
|
}
|
|
return {
|
|
kind: "chat_history",
|
|
id: `chat_history:${input.draftId}`,
|
|
attachment: input.payload.attachment,
|
|
source: {
|
|
serverId: input.serverId,
|
|
agentId: input.agentId,
|
|
boundaryMessageId: input.payload.boundaryMessageId,
|
|
boundaryCursor: input.payload.boundaryCursor,
|
|
itemCount: input.payload.itemCount,
|
|
},
|
|
};
|
|
}
|
|
|
|
function buildForkDraftSetup(agent: ForkAgentSource): WorkspaceDraftTabSetup | undefined {
|
|
if (!agent.provider) {
|
|
return undefined;
|
|
}
|
|
|
|
return {
|
|
provider: agent.provider,
|
|
cwd: agent.cwd,
|
|
modeId: agent.currentModeId ?? agent.runtimeInfo?.modeId ?? null,
|
|
model: agent.model ?? agent.runtimeInfo?.model ?? null,
|
|
thinkingOptionId: agent.thinkingOptionId ?? agent.runtimeInfo?.thinkingOptionId ?? null,
|
|
providerOptions: { ...agent.options },
|
|
};
|
|
}
|
|
|
|
function buildForkDraftTabTarget(
|
|
setup: WorkspaceDraftTabSetup | undefined,
|
|
draftId: string,
|
|
): WorkspaceTabTarget {
|
|
return setup ? { kind: "draft", draftId, setup } : { kind: "draft", draftId };
|
|
}
|
|
|
|
/**
|
|
* Shared fork driver behind both turn-footer fork affordances: the completed
|
|
* turn's footer (which supplies a boundary pinned to that turn) and the
|
|
* in-flight turn's footer next to the progress loader (which omits the boundary
|
|
* so the fork captures the still-streaming response).
|
|
*/
|
|
export function useForkAgent(
|
|
input: UseForkAgentInput,
|
|
): (request: ForkAgentRequest) => Promise<void> {
|
|
const { serverId, toast, readOnly = false } = input;
|
|
const { t } = useTranslation();
|
|
const router = useRouter();
|
|
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
|
const supportsAgentForkContext = useHostFeature(serverId, "agentForkContext") && !readOnly;
|
|
|
|
return useStableEvent(async ({ agentId, agent, workspaceId, target, boundary }) => {
|
|
try {
|
|
if (!supportsAgentForkContext) {
|
|
toast?.error(t("message.actions.forkUnavailable"));
|
|
return;
|
|
}
|
|
if (!client) {
|
|
throw new Error(t("workspace.terminal.hostDisconnected"));
|
|
}
|
|
const draftSetup = buildForkDraftSetup(agent);
|
|
const prepareForkDraft = async () => {
|
|
const draftId = generateDraftId();
|
|
const payload = await client.buildAgentForkContext(agentId, boundary);
|
|
const attachment = buildChatHistoryAttachment({
|
|
draftId,
|
|
serverId,
|
|
agentId,
|
|
payload,
|
|
missingAttachmentMessage: t("message.actions.forkFailed"),
|
|
});
|
|
useWorkspaceAttachmentsStore.getState().setWorkspaceAttachments({
|
|
scopeKey: buildDraftWorkspaceAttachmentScopeKey(draftId),
|
|
attachments: [attachment],
|
|
});
|
|
return draftId;
|
|
};
|
|
|
|
if (target === "tab") {
|
|
if (!workspaceId) {
|
|
throw new Error(t("message.actions.forkMissingWorkspace"));
|
|
}
|
|
const draftId = await prepareForkDraft();
|
|
navigateToWorkspace({
|
|
serverId,
|
|
workspaceId,
|
|
target: buildForkDraftTabTarget(draftSetup, draftId),
|
|
});
|
|
return;
|
|
}
|
|
|
|
const draftId = await prepareForkDraft();
|
|
const sourceDirectory =
|
|
agent.projectPlacement?.checkout?.cwd?.trim() || agent.cwd.trim() || undefined;
|
|
if (draftSetup) {
|
|
useWorkspaceDraftSubmissionStore.getState().setDraftSetup({
|
|
draftId,
|
|
setup: draftSetup,
|
|
sourceDirectory,
|
|
});
|
|
}
|
|
router.push(
|
|
buildNewWorkspaceRoute({
|
|
serverId,
|
|
sourceDirectory,
|
|
displayName: agent.projectPlacement?.projectName,
|
|
projectId: agent.projectPlacement?.projectKey,
|
|
draftId,
|
|
}),
|
|
);
|
|
} catch (error) {
|
|
toast?.error(toErrorMessage(error) || t("message.actions.forkFailed"));
|
|
}
|
|
});
|
|
}
|