diff --git a/apps/code/src/renderer/components/MainLayout.tsx b/apps/code/src/renderer/components/MainLayout.tsx index 70b0dee87..ce3690e68 100644 --- a/apps/code/src/renderer/components/MainLayout.tsx +++ b/apps/code/src/renderer/components/MainLayout.tsx @@ -20,6 +20,7 @@ import { useVisualTaskOrder } from "@features/sidebar/hooks/useVisualTaskOrder"; import { SkillsView } from "@features/skills/components/SkillsView"; import { TaskDetail } from "@features/task-detail/components/TaskDetail"; import { TaskInput } from "@features/task-detail/components/TaskInput"; +import { TaskPendingView } from "@features/task-detail/components/TaskPendingView"; import { useTasks } from "@features/tasks/hooks/useTasks"; import { TourOverlay } from "@features/tour/components/TourOverlay"; import { @@ -158,6 +159,10 @@ export function MainLayout() { )} + {view.type === "task-pending" && view.pendingTaskKey && ( + + )} + {view.type === "folder-settings" && } {view.type === "inbox" && } @@ -176,7 +181,7 @@ export function MainLayout() { tasks={visualTaskOrder} activeTaskId={activeTaskId} allTasks={tasks ?? []} - isOnNewTask={view.type === "task-input"} + isOnNewTask={view.type === "task-input" || view.type === "task-pending"} onNavigateToTask={navigateToTask} onNewTask={navigateToTaskInput} /> diff --git a/apps/code/src/renderer/features/sessions/components/PendingChatView.tsx b/apps/code/src/renderer/features/sessions/components/PendingChatView.tsx new file mode 100644 index 000000000..e657e41f3 --- /dev/null +++ b/apps/code/src/renderer/features/sessions/components/PendingChatView.tsx @@ -0,0 +1,43 @@ +import type { UserMessageAttachment } from "@features/sessions/components/session-update/UserMessage"; +import { CHAT_CONTENT_MAX_WIDTH } from "@features/sessions/constants"; +import { Brain } from "@phosphor-icons/react"; +import { Box, Flex, Text } from "@radix-ui/themes"; +import { PendingInputPlaceholder } from "./PendingInputPlaceholder"; +import { UserMessage } from "./session-update/UserMessage"; + +interface PendingChatViewProps { + promptText: string; + attachments?: UserMessageAttachment[]; +} + +export function PendingChatView({ + promptText, + attachments, +}: PendingChatViewProps) { + return ( + + + + + + + Starting task... + + + + + + + + ); +} diff --git a/apps/code/src/renderer/features/sessions/components/PendingInputPlaceholder.tsx b/apps/code/src/renderer/features/sessions/components/PendingInputPlaceholder.tsx new file mode 100644 index 000000000..571fb1cdf --- /dev/null +++ b/apps/code/src/renderer/features/sessions/components/PendingInputPlaceholder.tsx @@ -0,0 +1,28 @@ +import { Box, Flex } from "@radix-ui/themes"; + +/** + * Non-interactive skeleton sized to match {@link PromptInput} so the chat + * shell does not jump when the real editor mounts after session init. + */ +export function PendingInputPlaceholder() { + return ( + + + + + + + + + + + + ); +} diff --git a/apps/code/src/renderer/features/sessions/components/SessionView.tsx b/apps/code/src/renderer/features/sessions/components/SessionView.tsx index b95675e7d..49b9cdfa9 100644 --- a/apps/code/src/renderer/features/sessions/components/SessionView.tsx +++ b/apps/code/src/renderer/features/sessions/components/SessionView.tsx @@ -29,6 +29,10 @@ import { isJsonRpcNotification, isJsonRpcResponse, } from "@shared/types/session-events"; +import { + pendingTaskPromptStoreApi, + usePendingTaskPrompt, +} from "@stores/pendingTaskPromptStore"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { getSessionService } from "../service/service"; import { flattenSelectOptions } from "../stores/sessionStore"; @@ -40,6 +44,7 @@ import { CloudInitializingView } from "./CloudInitializingView"; import { ConversationView } from "./ConversationView"; import { DropZoneOverlay } from "./DropZoneOverlay"; import { ModelSelector } from "./ModelSelector"; +import { PendingChatView } from "./PendingChatView"; import { PlanStatusBar } from "./PlanStatusBar"; import { ReasoningLevelSelector } from "./ReasoningLevelSelector"; import { RawLogsView } from "./raw-logs/RawLogsView"; @@ -128,6 +133,7 @@ export function SessionView({ }: SessionViewProps) { const showRawLogs = useShowRawLogs(); const { setShowRawLogs } = useSessionViewActions(); + const pendingTaskPrompt = usePendingTaskPrompt(taskId); const pendingPermissions = usePendingPermissionsForTask(taskId); const modeOption = useModeConfigOptionForTask(taskId); const thoughtOption = useThoughtLevelConfigOptionForTask(taskId); @@ -138,6 +144,12 @@ export function SessionView({ const handoffInProgress = useSessionForTask(taskId)?.handoffInProgress ?? false; + useEffect(() => { + if (!taskId) return; + if (isInitializing) return; + pendingTaskPromptStoreApi.clear(taskId); + }, [taskId, isInitializing]); + useEffect(() => { if (allowBypassPermissions) return; // Cloud runs execute in an isolated sandbox where bypass is safe, and the @@ -512,6 +524,11 @@ export function SessionView({ ) : isInitializing ? ( isCloud ? ( + ) : pendingTaskPrompt?.promptText ? ( + ) : ( state.navigateToTaskInput, ); const isOnTaskInput = useNavigationStore( - (state) => state.view.type === "task-input", + (state) => + state.view.type === "task-input" || state.view.type === "task-pending", ); // biome-ignore lint/correctness/useExhaustiveDependencies: reset pagination when filters change diff --git a/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts b/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts index 66a6ef00e..c7a49b150 100644 --- a/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts +++ b/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts @@ -65,6 +65,7 @@ export interface SidebarData { interface ViewState { type: | "task-detail" + | "task-pending" | "task-input" | "settings" | "folder-settings" @@ -217,7 +218,8 @@ export function useSidebarData({ const sortMode = useSidebarStore((state) => state.sortMode); const folderOrder = useSidebarStore((state) => state.folderOrder); - const isHomeActive = activeView.type === "task-input"; + const isHomeActive = + activeView.type === "task-input" || activeView.type === "task-pending"; const isInboxActive = activeView.type === "inbox"; const isCommandCenterActive = activeView.type === "command-center"; const isSkillsActive = activeView.type === "skills"; diff --git a/apps/code/src/renderer/features/task-detail/components/TaskPendingView.tsx b/apps/code/src/renderer/features/task-detail/components/TaskPendingView.tsx new file mode 100644 index 000000000..37563e108 --- /dev/null +++ b/apps/code/src/renderer/features/task-detail/components/TaskPendingView.tsx @@ -0,0 +1,20 @@ +import { PendingChatView } from "@features/sessions/components/PendingChatView"; +import { Box } from "@radix-ui/themes"; +import { usePendingTaskPrompt } from "@stores/pendingTaskPromptStore"; + +interface TaskPendingViewProps { + pendingTaskKey: string; +} + +export function TaskPendingView({ pendingTaskKey }: TaskPendingViewProps) { + const pending = usePendingTaskPrompt(pendingTaskKey); + + return ( + + + + ); +} diff --git a/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts b/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts index 061d40a91..3d4fe4d7c 100644 --- a/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts +++ b/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts @@ -3,6 +3,7 @@ import { buildCloudTaskDescription } from "@features/editor/utils/cloud-prompt"; import { useTaskInputHistoryStore } from "@features/message-editor/stores/taskInputHistoryStore"; import type { EditorHandle } from "@features/message-editor/types"; import { + contentToPlainText, contentToXml, type EditorContent, extractFilePaths, @@ -20,6 +21,7 @@ import { toast } from "@renderer/utils/toast"; import type { ExecutionMode, Task } from "@shared/types"; import { ANALYTICS_EVENTS } from "@shared/types/analytics"; import { useNavigationStore } from "@stores/navigationStore"; +import { pendingTaskPromptStoreApi } from "@stores/pendingTaskPromptStore"; import { track } from "@utils/analytics"; import { logger } from "@utils/logger"; import { useCallback, useState } from "react"; @@ -187,8 +189,12 @@ export function useTaskCreation({ onTaskCreated, }: UseTaskCreationOptions): UseTaskCreationReturn { const [isCreatingTask, setIsCreatingTask] = useState(false); - const { clearTaskInputReportAssociation, navigateToTask } = - useNavigationStore(); + const { + clearTaskInputReportAssociation, + navigateToTask, + navigateToPendingTask, + navigateToTaskInput, + } = useNavigationStore(); const isAuthenticated = useAuthStateValue( (state) => state.status === "authenticated", ); @@ -210,11 +216,30 @@ export function useTaskCreation({ setIsCreatingTask(true); - try { - const content = contentOverride ?? editor.getContent(); + const content = contentOverride ?? editor.getContent(); + const plainPromptText = contentToPlainText(content).trim(); + const shouldShowPendingView = !onTaskCreated && !!plainPromptText; + const pendingTaskKey = shouldShowPendingView + ? (globalThis.crypto?.randomUUID?.() ?? `pending-${Date.now()}`) + : null; + + if (pendingTaskKey) { + pendingTaskPromptStoreApi.set(pendingTaskKey, { + promptText: plainPromptText, + attachments: (content.attachments ?? []).map((a) => ({ + id: a.id, + label: a.label, + })), + }); + navigateToPendingTask(pendingTaskKey); + if (!contentOverride) { + editor.clear(); + } + } + try { if (!contentOverride) { - const plainText = editor.getText()?.trim(); + const plainText = editor.getText()?.trim() ?? plainPromptText; if (plainText) { useTaskInputHistoryStore.getState().addPrompt(plainText); } @@ -246,13 +271,16 @@ export function useTaskCreation({ if (signalReportId) { clearTaskInputReportAssociation(); } + if (pendingTaskKey) { + pendingTaskPromptStoreApi.move(pendingTaskKey, output.task.id); + } if (onTaskCreated) { onTaskCreated(output.task); } else { navigateToTask(output.task); } useTourStore.getState().completeTour(createFirstTaskTour.id); - if (!contentOverride) { + if (!pendingTaskKey && !contentOverride) { editor.clear(); } }); @@ -268,6 +296,10 @@ export function useTaskCreation({ failedStep: result.failedStep, error: result.error, }); + if (pendingTaskKey) { + pendingTaskPromptStoreApi.clear(pendingTaskKey); + navigateToTaskInput({ initialPrompt: plainPromptText }); + } } return result.success; } catch (error) { @@ -275,6 +307,10 @@ export function useTaskCreation({ error instanceof Error ? error.message : "Unknown error"; toast.error("Failed to create task", { description }); log.error("Unexpected error during task creation", { error }); + if (pendingTaskKey) { + pendingTaskPromptStoreApi.clear(pendingTaskKey); + navigateToTaskInput({ initialPrompt: plainPromptText }); + } return false; } finally { setIsCreatingTask(false); @@ -300,6 +336,8 @@ export function useTaskCreation({ clearTaskInputReportAssociation, invalidateTasks, navigateToTask, + navigateToPendingTask, + navigateToTaskInput, onTaskCreated, ], ); diff --git a/apps/code/src/renderer/stores/navigationStore.test.ts b/apps/code/src/renderer/stores/navigationStore.test.ts index 0a6584ee8..c5fa8232c 100644 --- a/apps/code/src/renderer/stores/navigationStore.test.ts +++ b/apps/code/src/renderer/stores/navigationStore.test.ts @@ -199,6 +199,27 @@ describe("navigationStore", () => { type: "inbox", }); }); + + it("navigates to pending task with key", () => { + getStore().navigateToPendingTask("pending-key-123"); + expect(getView()).toMatchObject({ + type: "task-pending", + pendingTaskKey: "pending-key-123", + }); + }); + + it("replaces task-pending in history when navigating to real task", async () => { + getStore().navigateToTaskInput(); + getStore().navigateToPendingTask("pending-key-123"); + const indexBeforeReal = getStore().history.length - 1; + expect(getStore().history[indexBeforeReal].type).toBe("task-pending"); + + await getStore().navigateToTask(mockTask); + + const finalHistory = getStore().history; + expect(finalHistory[finalHistory.length - 1].type).toBe("task-detail"); + expect(finalHistory.some((v) => v.type === "task-pending")).toBe(false); + }); }); describe("history", () => { diff --git a/apps/code/src/renderer/stores/navigationStore.ts b/apps/code/src/renderer/stores/navigationStore.ts index bb43e334a..c3f4d8b2a 100644 --- a/apps/code/src/renderer/stores/navigationStore.ts +++ b/apps/code/src/renderer/stores/navigationStore.ts @@ -14,6 +14,7 @@ const log = logger.scope("navigation-store"); type ViewType = | "task-detail" + | "task-pending" | "task-input" | "folder-settings" | "inbox" @@ -43,6 +44,7 @@ interface ViewState { initialPrompt?: string; initialCloudRepository?: string; reportAssociation?: TaskInputReportAssociation; + pendingTaskKey?: string; } interface NavigationStore { @@ -52,6 +54,7 @@ interface NavigationStore { taskInputReportAssociation?: TaskInputReportAssociation; taskInputCloudRepository?: string; navigateToTask: (task: Task) => void; + navigateToPendingTask: (pendingTaskKey: string) => void; navigateToTaskInput: ( folderIdOrOptions?: string | TaskInputNavigationOptions, ) => void; @@ -74,6 +77,9 @@ const isSameView = (view1: ViewState, view2: ViewState): boolean => { if (view1.type === "task-detail" && view2.type === "task-detail") { return view1.data?.id === view2.data?.id; } + if (view1.type === "task-pending" && view2.type === "task-pending") { + return view1.pendingTaskKey === view2.pendingTaskKey; + } if (view1.type === "task-input" && view2.type === "task-input") { return ( view1.folderId === view2.folderId && @@ -109,7 +115,14 @@ export const useNavigationStore = create()( if (isSameView(view, newView)) { return; } - const newHistory = [...history.slice(0, historyIndex + 1), newView]; + // Replace transient task-pending entries instead of stacking them in + // history — going back to a pending view after the real task lands + // would render an empty placeholder. + const baseHistory = + view.type === "task-pending" + ? history.slice(0, historyIndex) + : history.slice(0, historyIndex + 1); + const newHistory = [...baseHistory, newView]; set({ view: newView, history: newHistory, @@ -186,6 +199,10 @@ export const useNavigationStore = create()( } }, + navigateToPendingTask: (pendingTaskKey: string) => { + navigate({ type: "task-pending", pendingTaskKey }); + }, + navigateToTaskInput: (folderIdOrOptions) => { const options = typeof folderIdOrOptions === "string" @@ -326,11 +343,14 @@ export const useNavigationStore = create()( name: "navigation-storage", storage: electronStorage, partialize: (state) => ({ - view: { - type: state.view.type, - taskId: state.view.taskId, - folderId: state.view.folderId, - }, + view: + state.view.type === "task-pending" + ? { type: "task-input" as const } + : { + type: state.view.type, + taskId: state.view.taskId, + folderId: state.view.folderId, + }, }), }, ), diff --git a/apps/code/src/renderer/stores/pendingTaskPromptStore.ts b/apps/code/src/renderer/stores/pendingTaskPromptStore.ts new file mode 100644 index 000000000..2412bd954 --- /dev/null +++ b/apps/code/src/renderer/stores/pendingTaskPromptStore.ts @@ -0,0 +1,56 @@ +import type { UserMessageAttachment } from "@features/sessions/components/session-update/UserMessage"; +import { create } from "zustand"; + +export interface PendingTaskPrompt { + promptText: string; + attachments: UserMessageAttachment[]; +} + +interface PendingTaskPromptStore { + byKey: Record; + set: (key: string, prompt: PendingTaskPrompt) => void; + get: (key: string) => PendingTaskPrompt | undefined; + move: (fromKey: string, toKey: string) => void; + clear: (key: string) => void; +} + +export const usePendingTaskPromptStore = create( + (set, get) => ({ + byKey: {}, + set: (key, prompt) => + set((state) => ({ byKey: { ...state.byKey, [key]: prompt } })), + get: (key) => get().byKey[key], + move: (fromKey, toKey) => { + if (fromKey === toKey) return; + set((state) => { + const entry = state.byKey[fromKey]; + if (!entry) return state; + const { [fromKey]: _removed, ...rest } = state.byKey; + return { byKey: { ...rest, [toKey]: entry } }; + }); + }, + clear: (key) => + set((state) => { + if (!(key in state.byKey)) return state; + const { [key]: _removed, ...rest } = state.byKey; + return { byKey: rest }; + }), + }), +); + +export const pendingTaskPromptStoreApi = { + set: (key: string, prompt: PendingTaskPrompt) => + usePendingTaskPromptStore.getState().set(key, prompt), + get: (key: string) => usePendingTaskPromptStore.getState().get(key), + move: (fromKey: string, toKey: string) => + usePendingTaskPromptStore.getState().move(fromKey, toKey), + clear: (key: string) => usePendingTaskPromptStore.getState().clear(key), +}; + +export function usePendingTaskPrompt( + key: string | undefined, +): PendingTaskPrompt | undefined { + return usePendingTaskPromptStore((state) => + key ? state.byKey[key] : undefined, + ); +}