Show optimistic chat for new workspaces
This commit is contained in:
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||||
|
import type { UserMessageImageAttachment } from "@/types/stream";
|
||||||
|
import type { AgentAttachment } from "@server/shared/messages";
|
||||||
|
import { useDraftAgentCreateFlow, type DraftCreateAttempt } from "./use-draft-agent-create-flow";
|
||||||
|
|
||||||
|
describe("useDraftAgentCreateFlow", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useCreateFlowStore.setState({ pendingByDraftId: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a prepared new-workspace create attempt as optimistic chat before continuing it", async () => {
|
||||||
|
const image: UserMessageImageAttachment = {
|
||||||
|
id: "image-1",
|
||||||
|
mimeType: "image/png",
|
||||||
|
storageType: "web-indexeddb",
|
||||||
|
storageKey: "image-key",
|
||||||
|
createdAt: 123,
|
||||||
|
};
|
||||||
|
const attachment = {
|
||||||
|
type: "review",
|
||||||
|
cwd: "/repo",
|
||||||
|
summary: "Review",
|
||||||
|
} as unknown as AgentAttachment;
|
||||||
|
const attempt: DraftCreateAttempt = {
|
||||||
|
clientMessageId: "msg-prepared",
|
||||||
|
text: "build this",
|
||||||
|
timestamp: new Date("2026-05-25T00:00:00.000Z"),
|
||||||
|
images: [image],
|
||||||
|
attachments: [attachment],
|
||||||
|
};
|
||||||
|
const createRequest = vi.fn(
|
||||||
|
async (ctx: {
|
||||||
|
attempt: DraftCreateAttempt;
|
||||||
|
text: string;
|
||||||
|
images?: UserMessageImageAttachment[];
|
||||||
|
attachments?: AgentAttachment[];
|
||||||
|
cwd: string;
|
||||||
|
}) => ({
|
||||||
|
agentId: "agent-1",
|
||||||
|
result: { id: "agent-1", ctx },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const onCreateSuccess = vi.fn();
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useDraftAgentCreateFlow({
|
||||||
|
draftId: "draft-1",
|
||||||
|
getPendingServerId: () => "server-1",
|
||||||
|
initialAttempt: attempt,
|
||||||
|
buildDraftAgent: (currentAttempt) => ({ currentAttempt }),
|
||||||
|
createRequest,
|
||||||
|
onCreateSuccess,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.isSubmitting).toBe(true);
|
||||||
|
expect(result.current.draftAgent).toEqual({ currentAttempt: attempt });
|
||||||
|
expect(result.current.optimisticStreamItems).toEqual([
|
||||||
|
{
|
||||||
|
kind: "user_message",
|
||||||
|
id: "msg-prepared",
|
||||||
|
text: "build this",
|
||||||
|
timestamp: attempt.timestamp,
|
||||||
|
optimistic: true,
|
||||||
|
images: [image],
|
||||||
|
attachments: [attachment],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(createRequest).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.continueCreateFromAttempt({ attempt, cwd: "/repo" });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(createRequest).toHaveBeenCalledTimes(1);
|
||||||
|
expect(createRequest).toHaveBeenCalledWith({
|
||||||
|
attempt,
|
||||||
|
text: "build this",
|
||||||
|
images: [image],
|
||||||
|
attachments: [attachment],
|
||||||
|
cwd: "/repo",
|
||||||
|
});
|
||||||
|
expect(onCreateSuccess).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -81,6 +81,7 @@ interface CreateRequestContext {
|
|||||||
interface UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult> {
|
interface UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult> {
|
||||||
draftId: string;
|
draftId: string;
|
||||||
getPendingServerId: () => string | null;
|
getPendingServerId: () => string | null;
|
||||||
|
initialAttempt?: CreateAttempt | null;
|
||||||
allowEmptyText?: boolean;
|
allowEmptyText?: boolean;
|
||||||
validateBeforeSubmit?: (ctx: SubmitContext) => string | null;
|
validateBeforeSubmit?: (ctx: SubmitContext) => string | null;
|
||||||
onBeforeSubmit?: (ctx: CreateRequestContext) => void;
|
onBeforeSubmit?: (ctx: CreateRequestContext) => void;
|
||||||
@@ -94,6 +95,7 @@ interface UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult> {
|
|||||||
export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||||
draftId,
|
draftId,
|
||||||
getPendingServerId,
|
getPendingServerId,
|
||||||
|
initialAttempt = null,
|
||||||
allowEmptyText = false,
|
allowEmptyText = false,
|
||||||
validateBeforeSubmit,
|
validateBeforeSubmit,
|
||||||
onBeforeSubmit,
|
onBeforeSubmit,
|
||||||
@@ -103,10 +105,17 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
|||||||
onCreateSuccess,
|
onCreateSuccess,
|
||||||
onCreateError,
|
onCreateError,
|
||||||
}: UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult>) {
|
}: UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult>) {
|
||||||
const [machine, dispatch] = useReducer(reducer, {
|
const [machine, dispatch] = useReducer(
|
||||||
tag: "draft",
|
reducer,
|
||||||
errorMessage: "",
|
initialAttempt,
|
||||||
} as DraftAgentMachineState);
|
(attempt): DraftAgentMachineState =>
|
||||||
|
attempt
|
||||||
|
? { tag: "creating", attempt }
|
||||||
|
: {
|
||||||
|
tag: "draft",
|
||||||
|
errorMessage: "",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const setPendingCreateAttempt = useCreateFlowStore((state) => state.setPending);
|
const setPendingCreateAttempt = useCreateFlowStore((state) => state.setPending);
|
||||||
const updatePendingAgentId = useCreateFlowStore((state) => state.updateAgentId);
|
const updatePendingAgentId = useCreateFlowStore((state) => state.updateAgentId);
|
||||||
@@ -150,6 +159,73 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
|||||||
return buildDraftAgent(machine.attempt);
|
return buildDraftAgent(machine.attempt);
|
||||||
}, [buildDraftAgent, machine]);
|
}, [buildDraftAgent, machine]);
|
||||||
|
|
||||||
|
const runCreateAttempt = useCallback(
|
||||||
|
async ({ attempt, cwd }: { attempt: CreateAttempt; cwd: string }) => {
|
||||||
|
const pendingServerId = getPendingServerId();
|
||||||
|
if (!pendingServerId) {
|
||||||
|
const error = new Error("No host selected");
|
||||||
|
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeSubmit?.({
|
||||||
|
attempt,
|
||||||
|
text: attempt.text,
|
||||||
|
images: attempt.images,
|
||||||
|
attachments: attempt.attachments,
|
||||||
|
cwd,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const createResult = await createRequest({
|
||||||
|
attempt,
|
||||||
|
text: attempt.text,
|
||||||
|
images: attempt.images,
|
||||||
|
attachments: attempt.attachments,
|
||||||
|
cwd,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (createResult.agentId) {
|
||||||
|
updatePendingAgentId({ draftId, agentId: createResult.agentId });
|
||||||
|
appendOptimisticUserMessageToAgentStream(
|
||||||
|
pendingServerId,
|
||||||
|
createResult.agentId,
|
||||||
|
buildOptimisticUserMessage({
|
||||||
|
id: attempt.clientMessageId,
|
||||||
|
text: attempt.text,
|
||||||
|
timestamp: attempt.timestamp,
|
||||||
|
images: attempt.images,
|
||||||
|
attachments: attempt.attachments,
|
||||||
|
}),
|
||||||
|
{ placement: "tail", skipIfUserMessageExists: true },
|
||||||
|
);
|
||||||
|
markPendingCreateLifecycle({ draftId, lifecycle: "sent" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await onCreateSuccess({ result: createResult.result, attempt });
|
||||||
|
} catch (error) {
|
||||||
|
const resolved = error instanceof Error ? error : new Error("Failed to create agent");
|
||||||
|
dispatch({ type: "CREATE_FAILED", message: resolved.message });
|
||||||
|
markPendingCreateLifecycle({ draftId, lifecycle: "abandoned" });
|
||||||
|
clearPendingCreateAttempt({ draftId });
|
||||||
|
onCreateError?.(resolved);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
appendOptimisticUserMessageToAgentStream,
|
||||||
|
clearPendingCreateAttempt,
|
||||||
|
createRequest,
|
||||||
|
draftId,
|
||||||
|
getPendingServerId,
|
||||||
|
markPendingCreateLifecycle,
|
||||||
|
onBeforeSubmit,
|
||||||
|
onCreateError,
|
||||||
|
onCreateSuccess,
|
||||||
|
updatePendingAgentId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
const handleCreateFromInput = useCallback(
|
const handleCreateFromInput = useCallback(
|
||||||
async ({ text, attachments, cwd }: SubmitContext) => {
|
async ({ text, attachments, cwd }: SubmitContext) => {
|
||||||
if (isSubmitting) {
|
if (isSubmitting) {
|
||||||
@@ -206,71 +282,32 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
|||||||
: {}),
|
: {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeSubmit?.({
|
|
||||||
attempt,
|
|
||||||
text: trimmedPrompt,
|
|
||||||
images,
|
|
||||||
attachments: wirePayload.attachments,
|
|
||||||
cwd,
|
|
||||||
});
|
|
||||||
dispatch({ type: "SUBMIT", attempt });
|
dispatch({ type: "SUBMIT", attempt });
|
||||||
onCreateStart?.();
|
onCreateStart?.();
|
||||||
|
await runCreateAttempt({ attempt, cwd });
|
||||||
try {
|
|
||||||
const createResult = await createRequest({
|
|
||||||
attempt,
|
|
||||||
text: trimmedPrompt,
|
|
||||||
images,
|
|
||||||
attachments: wirePayload.attachments,
|
|
||||||
cwd,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (createResult.agentId) {
|
|
||||||
updatePendingAgentId({ draftId, agentId: createResult.agentId });
|
|
||||||
appendOptimisticUserMessageToAgentStream(
|
|
||||||
pendingServerId,
|
|
||||||
createResult.agentId,
|
|
||||||
buildOptimisticUserMessage({
|
|
||||||
id: attempt.clientMessageId,
|
|
||||||
text: attempt.text,
|
|
||||||
timestamp: attempt.timestamp,
|
|
||||||
images: attempt.images,
|
|
||||||
attachments: attempt.attachments,
|
|
||||||
}),
|
|
||||||
{ placement: "tail", skipIfUserMessageExists: true },
|
|
||||||
);
|
|
||||||
markPendingCreateLifecycle({ draftId, lifecycle: "sent" });
|
|
||||||
}
|
|
||||||
|
|
||||||
await onCreateSuccess({ result: createResult.result, attempt });
|
|
||||||
} catch (error) {
|
|
||||||
const resolved = error instanceof Error ? error : new Error("Failed to create agent");
|
|
||||||
dispatch({ type: "CREATE_FAILED", message: resolved.message });
|
|
||||||
markPendingCreateLifecycle({ draftId, lifecycle: "abandoned" });
|
|
||||||
clearPendingCreateAttempt({ draftId });
|
|
||||||
onCreateError?.(resolved);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
appendOptimisticUserMessageToAgentStream,
|
allowEmptyText,
|
||||||
clearPendingCreateAttempt,
|
|
||||||
createRequest,
|
|
||||||
draftId,
|
draftId,
|
||||||
getPendingServerId,
|
getPendingServerId,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
markPendingCreateLifecycle,
|
|
||||||
onBeforeSubmit,
|
|
||||||
onCreateError,
|
|
||||||
onCreateStart,
|
onCreateStart,
|
||||||
onCreateSuccess,
|
runCreateAttempt,
|
||||||
setPendingCreateAttempt,
|
setPendingCreateAttempt,
|
||||||
updatePendingAgentId,
|
|
||||||
validateBeforeSubmit,
|
validateBeforeSubmit,
|
||||||
allowEmptyText,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const continueCreateFromAttempt = useCallback(
|
||||||
|
async ({ attempt, cwd }: { attempt: CreateAttempt; cwd: string }) => {
|
||||||
|
if (!isSubmitting) {
|
||||||
|
dispatch({ type: "SUBMIT", attempt });
|
||||||
|
}
|
||||||
|
await runCreateAttempt({ attempt, cwd });
|
||||||
|
},
|
||||||
|
[isSubmitting, runCreateAttempt],
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
machine,
|
machine,
|
||||||
formErrorMessage,
|
formErrorMessage,
|
||||||
@@ -278,6 +315,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
|||||||
optimisticStreamItems,
|
optimisticStreamItems,
|
||||||
draftAgent,
|
draftAgent,
|
||||||
handleCreateFromInput,
|
handleCreateFromInput,
|
||||||
|
continueCreateFromAttempt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildDraftPanelDescriptor } from "@/panels/draft-panel-descriptor";
|
||||||
|
|
||||||
|
function TestIcon() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("buildDraftPanelDescriptor", () => {
|
||||||
|
it("uses the initial prompt title and running loader bucket during create", () => {
|
||||||
|
const descriptor = buildDraftPanelDescriptor({
|
||||||
|
isCreating: true,
|
||||||
|
pendingPrompt: "Build the dashboard",
|
||||||
|
icon: TestIcon,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(descriptor).toMatchObject({
|
||||||
|
label: "Build the dashboard",
|
||||||
|
subtitle: "Creating agent",
|
||||||
|
titleState: "ready",
|
||||||
|
statusBucket: "running",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the draft title for empty create prompts", () => {
|
||||||
|
const descriptor = buildDraftPanelDescriptor({
|
||||||
|
isCreating: true,
|
||||||
|
pendingPrompt: " ",
|
||||||
|
icon: TestIcon,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(descriptor.label).toBe("New Agent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps ordinary draft tabs labeled as new agents", () => {
|
||||||
|
const descriptor = buildDraftPanelDescriptor({ isCreating: false, icon: TestIcon });
|
||||||
|
|
||||||
|
expect(descriptor).toMatchObject({
|
||||||
|
label: "New Agent",
|
||||||
|
subtitle: "New Agent",
|
||||||
|
titleState: "ready",
|
||||||
|
statusBucket: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { DaemonClient } from "@server/client/daemon-client";
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
|
import { SquarePen } from "lucide-react-native";
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { ActivityIndicator, Text, View } from "react-native";
|
import { ActivityIndicator, Text, View } from "react-native";
|
||||||
import ReanimatedAnimated from "react-native-reanimated";
|
import ReanimatedAnimated from "react-native-reanimated";
|
||||||
@@ -36,6 +37,7 @@ import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
|||||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||||
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
|
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
|
||||||
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
||||||
|
import { buildDraftPanelDescriptor } from "@/panels/draft-panel-descriptor";
|
||||||
import {
|
import {
|
||||||
type HostRuntimeConnectionStatus,
|
type HostRuntimeConnectionStatus,
|
||||||
useHostRuntimeClient,
|
useHostRuntimeClient,
|
||||||
@@ -48,6 +50,7 @@ import {
|
|||||||
deriveRouteBottomAnchorIntent,
|
deriveRouteBottomAnchorIntent,
|
||||||
deriveRouteBottomAnchorRequest,
|
deriveRouteBottomAnchorRequest,
|
||||||
} from "@/screens/agent/agent-ready-screen-bottom-anchor";
|
} from "@/screens/agent/agent-ready-screen-bottom-anchor";
|
||||||
|
import { WorkspaceDraftAgentTab } from "@/screens/workspace/workspace-draft-agent-tab";
|
||||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||||
import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
|
import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
@@ -302,12 +305,82 @@ function AgentPanel() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DraftPanel() {
|
||||||
|
const {
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
tabId,
|
||||||
|
target,
|
||||||
|
openFileInWorkspace,
|
||||||
|
openImportSheet,
|
||||||
|
retargetCurrentTab,
|
||||||
|
} = usePaneContext();
|
||||||
|
const { isInteractive } = usePaneFocus();
|
||||||
|
invariant(target.kind === "draft", "DraftPanel requires draft target");
|
||||||
|
|
||||||
|
const handleCreated = useCallback(
|
||||||
|
(agentSnapshot: Parameters<typeof normalizeAgentSnapshot>[0]) => {
|
||||||
|
const normalized = normalizeAgentSnapshot(agentSnapshot, serverId);
|
||||||
|
useSessionStore.getState().setAgents(serverId, (prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(agentSnapshot.id, normalized);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id });
|
||||||
|
},
|
||||||
|
[retargetCurrentTab, serverId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WorkspaceDraftAgentTab
|
||||||
|
serverId={serverId}
|
||||||
|
workspaceId={workspaceId}
|
||||||
|
tabId={tabId}
|
||||||
|
draftId={target.draftId}
|
||||||
|
initialSetup={target.setup}
|
||||||
|
isPaneFocused={isInteractive}
|
||||||
|
onOpenWorkspaceFile={openFileInWorkspace}
|
||||||
|
onCreated={handleCreated}
|
||||||
|
onOpenImportSheet={openImportSheet}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentConversationPanel() {
|
||||||
|
const { target } = usePaneContext();
|
||||||
|
if (target.kind === "draft") {
|
||||||
|
return <DraftPanel />;
|
||||||
|
}
|
||||||
|
if (target.kind === "agent") {
|
||||||
|
return <AgentPanel />;
|
||||||
|
}
|
||||||
|
invariant(false, "AgentConversationPanel requires an agent or draft target");
|
||||||
|
}
|
||||||
|
|
||||||
export const agentPanelRegistration: PanelRegistration<"agent"> = {
|
export const agentPanelRegistration: PanelRegistration<"agent"> = {
|
||||||
kind: "agent",
|
kind: "agent",
|
||||||
component: AgentPanel,
|
component: AgentConversationPanel,
|
||||||
useDescriptor: useAgentPanelDescriptor,
|
useDescriptor: useAgentPanelDescriptor,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function useDraftPanelDescriptor(
|
||||||
|
target: { kind: "draft"; draftId: string },
|
||||||
|
context: { serverId: string },
|
||||||
|
) {
|
||||||
|
const pendingCreate = useCreateFlowStore((state) => {
|
||||||
|
const pending = state.pendingByDraftId[target.draftId];
|
||||||
|
return pending?.serverId === context.serverId && pending.lifecycle === "active"
|
||||||
|
? pending
|
||||||
|
: null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return buildDraftPanelDescriptor({
|
||||||
|
isCreating: Boolean(pendingCreate),
|
||||||
|
pendingPrompt: pendingCreate?.text,
|
||||||
|
icon: SquarePen,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
||||||
const EMPTY_PENDING_PERMISSIONS = new Map<string, PendingPermission>();
|
const EMPTY_PENDING_PERMISSIONS = new Map<string, PendingPermission>();
|
||||||
const EMPTY_PENDING_PERMISSION_LIST: PendingPermission[] = [];
|
const EMPTY_PENDING_PERMISSION_LIST: PendingPermission[] = [];
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { ComponentType } from "react";
|
||||||
|
import type { PanelDescriptor, PanelIconProps } from "@/panels/panel-registry";
|
||||||
|
|
||||||
|
export function buildDraftPanelDescriptor(input: {
|
||||||
|
isCreating: boolean;
|
||||||
|
pendingPrompt?: string | null;
|
||||||
|
icon: ComponentType<PanelIconProps>;
|
||||||
|
}): PanelDescriptor {
|
||||||
|
const { icon, isCreating, pendingPrompt } = input;
|
||||||
|
const creatingLabel = pendingPrompt?.trim() || "New Agent";
|
||||||
|
if (isCreating) {
|
||||||
|
return {
|
||||||
|
label: creatingLabel,
|
||||||
|
subtitle: "Creating agent",
|
||||||
|
titleState: "ready",
|
||||||
|
icon,
|
||||||
|
statusBucket: "running",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: "New Agent",
|
||||||
|
subtitle: "New Agent",
|
||||||
|
titleState: "ready",
|
||||||
|
icon,
|
||||||
|
statusBucket: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,65 +1,8 @@
|
|||||||
import { SquarePen } from "lucide-react-native";
|
import { AgentConversationPanel, useDraftPanelDescriptor } from "@/panels/agent-panel";
|
||||||
import { useCallback } from "react";
|
|
||||||
import invariant from "tiny-invariant";
|
|
||||||
import { WorkspaceDraftAgentTab } from "@/screens/workspace/workspace-draft-agent-tab";
|
|
||||||
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
|
|
||||||
import type { PanelRegistration } from "@/panels/panel-registry";
|
import type { PanelRegistration } from "@/panels/panel-registry";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
|
||||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
|
||||||
|
|
||||||
function useDraftPanelDescriptor() {
|
|
||||||
return {
|
|
||||||
label: "New Agent",
|
|
||||||
subtitle: "New Agent",
|
|
||||||
titleState: "ready" as const,
|
|
||||||
icon: SquarePen,
|
|
||||||
statusBucket: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function DraftPanel() {
|
|
||||||
const {
|
|
||||||
serverId,
|
|
||||||
workspaceId,
|
|
||||||
tabId,
|
|
||||||
target,
|
|
||||||
openFileInWorkspace,
|
|
||||||
openImportSheet,
|
|
||||||
retargetCurrentTab,
|
|
||||||
} = usePaneContext();
|
|
||||||
const { isInteractive } = usePaneFocus();
|
|
||||||
invariant(target.kind === "draft", "DraftPanel requires draft target");
|
|
||||||
|
|
||||||
const handleCreated = useCallback(
|
|
||||||
(agentSnapshot: Parameters<typeof normalizeAgentSnapshot>[0]) => {
|
|
||||||
const normalized = normalizeAgentSnapshot(agentSnapshot, serverId);
|
|
||||||
useSessionStore.getState().setAgents(serverId, (prev) => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.set(agentSnapshot.id, normalized);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id });
|
|
||||||
},
|
|
||||||
[retargetCurrentTab, serverId],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<WorkspaceDraftAgentTab
|
|
||||||
serverId={serverId}
|
|
||||||
workspaceId={workspaceId}
|
|
||||||
tabId={tabId}
|
|
||||||
draftId={target.draftId}
|
|
||||||
initialSetup={target.setup}
|
|
||||||
isPaneFocused={isInteractive}
|
|
||||||
onOpenWorkspaceFile={openFileInWorkspace}
|
|
||||||
onCreated={handleCreated}
|
|
||||||
onOpenImportSheet={openImportSheet}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const draftPanelRegistration: PanelRegistration<"draft"> = {
|
export const draftPanelRegistration: PanelRegistration<"draft"> = {
|
||||||
kind: "draft",
|
kind: "draft",
|
||||||
component: DraftPanel,
|
component: AgentConversationPanel,
|
||||||
useDescriptor: useDraftPanelDescriptor,
|
useDescriptor: useDraftPanelDescriptor,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,9 +23,11 @@ import { useGithubSearchQuery } from "@/git/use-github-search-query";
|
|||||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
|
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
|
||||||
import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
|
import { generateDraftId } from "@/stores/draft-keys";
|
||||||
import { useDraftStore } from "@/stores/draft-store";
|
import { useDraftStore } from "@/stores/draft-store";
|
||||||
|
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||||
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
||||||
|
import { generateMessageId } from "@/types/stream";
|
||||||
import { toErrorMessage } from "@/utils/error-messages";
|
import { toErrorMessage } from "@/utils/error-messages";
|
||||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types";
|
import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types";
|
||||||
@@ -458,27 +460,29 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void {
|
|||||||
composerState,
|
composerState,
|
||||||
} = input;
|
} = input;
|
||||||
const draftId = generateDraftId();
|
const draftId = generateDraftId();
|
||||||
useDraftStore.getState().saveDraftInput({
|
const clientMessageId = generateMessageId();
|
||||||
draftKey: buildDraftStoreKey({
|
const timestamp = Date.now();
|
||||||
serverId,
|
const wirePayload = splitComposerAttachmentsForSubmit(attachments);
|
||||||
agentId: draftId,
|
useCreateFlowStore.getState().setPending({
|
||||||
draftId,
|
serverId,
|
||||||
}),
|
draftId,
|
||||||
draft: {
|
agentId: null,
|
||||||
text,
|
clientMessageId,
|
||||||
attachments: attachments.filter(
|
text: text.trim(),
|
||||||
(attachment): attachment is UserComposerAttachment => attachment.kind !== "review",
|
timestamp,
|
||||||
),
|
...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}),
|
||||||
},
|
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
|
||||||
});
|
});
|
||||||
useWorkspaceDraftSubmissionStore.getState().setPending({
|
useWorkspaceDraftSubmissionStore.getState().setPending({
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
draftId,
|
draftId,
|
||||||
text,
|
text: text.trim(),
|
||||||
attachments,
|
attachments,
|
||||||
cwd: workspaceDirectory,
|
cwd: workspaceDirectory,
|
||||||
provider,
|
provider,
|
||||||
|
clientMessageId,
|
||||||
|
timestamp,
|
||||||
...(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
|
...(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
|
||||||
? { modeId: composerState.selectedMode }
|
? { modeId: composerState.selectedMode }
|
||||||
: {}),
|
: {}),
|
||||||
|
|||||||
@@ -11,11 +11,15 @@ import { composerWorkspaceAttachment } from "@/attachments/composer-workspace-at
|
|||||||
import type { ImageAttachment } from "@/components/message-input";
|
import type { ImageAttachment } from "@/components/message-input";
|
||||||
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
||||||
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
|
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
|
||||||
import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow";
|
import {
|
||||||
|
useDraftAgentCreateFlow,
|
||||||
|
type DraftCreateAttempt,
|
||||||
|
} from "@/hooks/use-draft-agent-create-flow";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config";
|
import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config";
|
||||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
|
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||||
import type { Agent } from "@/stores/session-store";
|
import type { Agent } from "@/stores/session-store";
|
||||||
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
|
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
|
||||||
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
||||||
@@ -348,10 +352,33 @@ export function WorkspaceDraftAgentTab({
|
|||||||
const pending = state.pendingByDraftId[draftId] ?? null;
|
const pending = state.pendingByDraftId[draftId] ?? null;
|
||||||
return pending?.serverId === serverId && pending.workspaceId === workspaceId ? pending : 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(
|
const consumePendingAutoSubmit = useWorkspaceDraftSubmissionStore(
|
||||||
(state) => state.consumePending,
|
(state) => state.consumePending,
|
||||||
);
|
);
|
||||||
const autoSubmitConfig = resolveAutoSubmitConfig(pendingAutoSubmit);
|
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 allowsEmptyAutoSubmit = pendingAutoSubmit?.allowEmptyText === true;
|
||||||
const isCompact = useIsCompactFormFactor();
|
const isCompact = useIsCompactFormFactor();
|
||||||
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
|
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
|
||||||
@@ -390,9 +417,11 @@ export function WorkspaceDraftAgentTab({
|
|||||||
optimisticStreamItems,
|
optimisticStreamItems,
|
||||||
draftAgent,
|
draftAgent,
|
||||||
handleCreateFromInput,
|
handleCreateFromInput,
|
||||||
|
continueCreateFromAttempt,
|
||||||
} = useDraftAgentCreateFlow<Agent, AgentSnapshotPayload>({
|
} = useDraftAgentCreateFlow<Agent, AgentSnapshotPayload>({
|
||||||
draftId,
|
draftId,
|
||||||
getPendingServerId: () => serverId,
|
getPendingServerId: () => serverId,
|
||||||
|
initialAttempt: initialCreateAttempt,
|
||||||
allowEmptyText: allowsEmptyAutoSubmit,
|
allowEmptyText: allowsEmptyAutoSubmit,
|
||||||
validateBeforeSubmit: ({ text }) =>
|
validateBeforeSubmit: ({ text }) =>
|
||||||
validateDraftSubmission({
|
validateDraftSubmission({
|
||||||
@@ -442,7 +471,6 @@ export function WorkspaceDraftAgentTab({
|
|||||||
draftInput.isHydrated &&
|
draftInput.isHydrated &&
|
||||||
draftWorkingDirectory &&
|
draftWorkingDirectory &&
|
||||||
client &&
|
client &&
|
||||||
!isSubmitting &&
|
|
||||||
!composerState.isModelLoading,
|
!composerState.isModelLoading,
|
||||||
);
|
);
|
||||||
const autoSubmitKeyRef = useRef<string | null>(null);
|
const autoSubmitKeyRef = useRef<string | null>(null);
|
||||||
@@ -461,19 +489,31 @@ export function WorkspaceDraftAgentTab({
|
|||||||
autoSubmitKeyRef.current = submitKey;
|
autoSubmitKeyRef.current = submitKey;
|
||||||
setDraftText("");
|
setDraftText("");
|
||||||
setDraftAttachments([]);
|
setDraftAttachments([]);
|
||||||
void handleCreateFromInput({
|
const preparedAttempt =
|
||||||
text: submission.text,
|
initialCreateAttempt?.clientMessageId === submission.clientMessageId
|
||||||
attachments: submission.attachments,
|
? initialCreateAttempt
|
||||||
cwd: submission.cwd,
|
: null;
|
||||||
}).catch(() => {
|
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);
|
setDraftText(submission.text);
|
||||||
setDraftAttachments(composerWorkspaceAttachment.userAttachmentsOnly(submission.attachments));
|
setDraftAttachments(composerWorkspaceAttachment.userAttachmentsOnly(submission.attachments));
|
||||||
autoSubmitKeyRef.current = null;
|
autoSubmitKeyRef.current = null;
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
|
continueCreateFromAttempt,
|
||||||
consumePendingAutoSubmit,
|
consumePendingAutoSubmit,
|
||||||
draftId,
|
draftId,
|
||||||
handleCreateFromInput,
|
handleCreateFromInput,
|
||||||
|
initialCreateAttempt,
|
||||||
isReadyForPendingAutoSubmit,
|
isReadyForPendingAutoSubmit,
|
||||||
serverId,
|
serverId,
|
||||||
setDraftAttachments,
|
setDraftAttachments,
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export interface PendingWorkspaceDraftSubmission {
|
|||||||
attachments: ComposerAttachment[];
|
attachments: ComposerAttachment[];
|
||||||
cwd: string;
|
cwd: string;
|
||||||
provider: AgentProvider;
|
provider: AgentProvider;
|
||||||
|
clientMessageId: string;
|
||||||
|
timestamp: number;
|
||||||
modeId?: string;
|
modeId?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
thinkingOptionId?: string;
|
thinkingOptionId?: string;
|
||||||
|
|||||||
@@ -1772,13 +1772,13 @@ export function reconcileWorkspaceTabs(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (group.tabs.some((tab) => tab.tabId === originalFocusedTabId)) {
|
if (group.tabs.some((tab) => tab.tabId === originalFocusedTabId)) {
|
||||||
reconciledFocusedTabId = canonicalTabId;
|
reconciledFocusedTabId = keeper.tabId;
|
||||||
}
|
}
|
||||||
if (keeper.tabId !== canonicalTabId || !workspaceTabTargetsEqual(keeper.target, group.target)) {
|
if (!workspaceTabTargetsEqual(keeper.target, group.target)) {
|
||||||
nextLayout = withNormalizedParentTabMap({
|
nextLayout = withNormalizedParentTabMap({
|
||||||
root: replaceTabInTree(asInternalLayout(nextLayout).root, {
|
root: replaceTabInTree(asInternalLayout(nextLayout).root, {
|
||||||
tabId: keeper.tabId,
|
tabId: keeper.tabId,
|
||||||
nextTabId: canonicalTabId,
|
nextTabId: keeper.tabId,
|
||||||
target: group.target,
|
target: group.target,
|
||||||
}),
|
}),
|
||||||
focusedPaneId: nextLayout.focusedPaneId,
|
focusedPaneId: nextLayout.focusedPaneId,
|
||||||
|
|||||||
@@ -1354,6 +1354,56 @@ describe("workspace-layout-store actions", () => {
|
|||||||
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe("agent_agent-1");
|
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe("agent_agent-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reconcileTabs preserves a draft-origin agent tab id when there is no duplicate", () => {
|
||||||
|
const workspaceKey = createWorkspaceKey();
|
||||||
|
|
||||||
|
workspaceLayoutStore.setState((state) => ({
|
||||||
|
...state,
|
||||||
|
layoutByWorkspace: {
|
||||||
|
...state.layoutByWorkspace,
|
||||||
|
[workspaceKey]: {
|
||||||
|
root: {
|
||||||
|
kind: "pane",
|
||||||
|
pane: {
|
||||||
|
id: "main",
|
||||||
|
tabIds: ["draft-agent"],
|
||||||
|
focusedTabId: "draft-agent",
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
tabId: "draft-agent",
|
||||||
|
target: { kind: "agent", agentId: "agent-1" },
|
||||||
|
createdAt: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as SplitPane,
|
||||||
|
},
|
||||||
|
focusedPaneId: "main",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
workspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
|
||||||
|
agentsHydrated: true,
|
||||||
|
terminalsHydrated: true,
|
||||||
|
activeAgentIds: ["agent-1"],
|
||||||
|
autoOpenAgentIds: ["agent-1"],
|
||||||
|
knownAgentIds: ["agent-1"],
|
||||||
|
standaloneTerminalIds: [],
|
||||||
|
hasActivePendingDraftCreate: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||||
|
|
||||||
|
expect(collectAllTabs(layout.root)).toEqual([
|
||||||
|
{
|
||||||
|
tabId: "draft-agent",
|
||||||
|
target: { kind: "agent", agentId: "agent-1" },
|
||||||
|
createdAt: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe("draft-agent");
|
||||||
|
});
|
||||||
|
|
||||||
it("reconcileTabs does not re-add locally hidden agent tabs", () => {
|
it("reconcileTabs does not re-add locally hidden agent tabs", () => {
|
||||||
const workspaceKey = createWorkspaceKey();
|
const workspaceKey = createWorkspaceKey();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user