Skip to content

Commit 8a8066f

Browse files
authored
feat(task-input): show prompt in chat thread immediately on submit (#2310)
## Summary Submitting a new task used to leave the user on `TaskInput` (with a spinning submit button) until the saga finished creating the task, folder, and workspace. Only then did `onTaskReady` fire and navigate to `TaskDetail`, where `SessionView` showed a full-screen spinner until the agent session connected and `applyOptimisticPrompt` wrote the user message into the optimistic store. The flow felt sluggish because each step blocked the UI from showing the prompt. This change navigates to a thread-style view synchronously on submit: - New `task-pending` view in `navigationStore` (transient — excluded from persistence; replaced in history on transition so back doesn't land on a stale placeholder). - `pendingTaskPromptStore` holds the prompt text keyed first by a client-generated UUID, then re-keyed to the real task id once the saga returns. - `PendingChatView` renders the user-message bubble + "Connecting to agent..." footer with the same layout as `SessionView`'s connected state. `TaskPendingView` wraps it for the view-router; `SessionView`'s initializing branch also renders it when a pending entry exists, bridging the gap until `applyOptimisticPrompt` fires. - `useTaskCreation.handleSubmit` stashes the prompt, navigates to the pending view, then runs the saga. On failure it clears the pending entry and navigates back to `task-input` with `initialPrompt` preserved. - `MainLayout`, `useSidebarData`, and `TaskListView` treat `task-pending` like `task-input` for sidebar/SpaceSwitcher state. `CommandCenterPanel`'s `onTaskCreated` override skips the pending view so its existing flow is untouched. https://github.com/user-attachments/assets/8153c233-d77d-445c-b683-eeba49f1d59e ## Test plan - [x] `pnpm --filter code typecheck` clean - [x] 2 new navigation-store tests cover `navigateToPendingTask` and the history-replace behavior - [x] All 914 renderer tests pass - [ ] Manual: submit a local task — confirm thread + prompt appear immediately, then spinner, then agent response streams in - [ ] Manual: submit a worktree task — pending view stays during provisioning, transitions seamlessly to `TaskDetail` - [ ] Manual: submit a cloud task — pending view shows during saga's cloud setup, then `CloudInitializingView` takes over - [ ] Manual: simulate a saga failure — pending view goes back to `task-input` with the prompt restored - [ ] Manual: submit from `CommandCenterPanel` — no pending view; existing flow unchanged - [ ] Manual: press back from `TaskDetail` after submit — lands on `task-input`, not the empty pending placeholder
1 parent 750d4ae commit 8a8066f

11 files changed

Lines changed: 266 additions & 15 deletions

File tree

apps/code/src/renderer/components/MainLayout.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { useVisualTaskOrder } from "@features/sidebar/hooks/useVisualTaskOrder";
2020
import { SkillsView } from "@features/skills/components/SkillsView";
2121
import { TaskDetail } from "@features/task-detail/components/TaskDetail";
2222
import { TaskInput } from "@features/task-detail/components/TaskInput";
23+
import { TaskPendingView } from "@features/task-detail/components/TaskPendingView";
2324
import { useTasks } from "@features/tasks/hooks/useTasks";
2425
import { TourOverlay } from "@features/tour/components/TourOverlay";
2526
import {
@@ -158,6 +159,10 @@ export function MainLayout() {
158159
<TaskDetail key={view.data.id} task={view.data} />
159160
)}
160161

162+
{view.type === "task-pending" && view.pendingTaskKey && (
163+
<TaskPendingView pendingTaskKey={view.pendingTaskKey} />
164+
)}
165+
161166
{view.type === "folder-settings" && <FolderSettingsView />}
162167

163168
{view.type === "inbox" && <InboxView />}
@@ -176,7 +181,7 @@ export function MainLayout() {
176181
tasks={visualTaskOrder}
177182
activeTaskId={activeTaskId}
178183
allTasks={tasks ?? []}
179-
isOnNewTask={view.type === "task-input"}
184+
isOnNewTask={view.type === "task-input" || view.type === "task-pending"}
180185
onNavigateToTask={navigateToTask}
181186
onNewTask={navigateToTaskInput}
182187
/>
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type { UserMessageAttachment } from "@features/sessions/components/session-update/UserMessage";
2+
import { CHAT_CONTENT_MAX_WIDTH } from "@features/sessions/constants";
3+
import { Brain } from "@phosphor-icons/react";
4+
import { Box, Flex, Text } from "@radix-ui/themes";
5+
import { PendingInputPlaceholder } from "./PendingInputPlaceholder";
6+
import { UserMessage } from "./session-update/UserMessage";
7+
8+
interface PendingChatViewProps {
9+
promptText: string;
10+
attachments?: UserMessageAttachment[];
11+
}
12+
13+
export function PendingChatView({
14+
promptText,
15+
attachments,
16+
}: PendingChatViewProps) {
17+
return (
18+
<Flex direction="column" className="absolute inset-0 bg-background">
19+
<Box className="min-h-0 flex-1 overflow-y-auto">
20+
<Box
21+
className="mx-auto flex flex-col gap-3 px-2 py-1.5"
22+
style={{ maxWidth: CHAT_CONTENT_MAX_WIDTH }}
23+
>
24+
<UserMessage
25+
content={promptText}
26+
attachments={attachments}
27+
animate={false}
28+
/>
29+
<Flex align="center" gap="2" className="pl-3">
30+
<Brain size={12} className="ph-pulse text-accent-11" />
31+
<Text className="text-[13px] text-accent-11">Starting task...</Text>
32+
</Flex>
33+
</Box>
34+
</Box>
35+
<Box
36+
className="mx-auto w-full p-2"
37+
style={{ maxWidth: CHAT_CONTENT_MAX_WIDTH }}
38+
>
39+
<PendingInputPlaceholder />
40+
</Box>
41+
</Flex>
42+
);
43+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { Box, Flex } from "@radix-ui/themes";
2+
3+
/**
4+
* Non-interactive skeleton sized to match {@link PromptInput} so the chat
5+
* shell does not jump when the real editor mounts after session init.
6+
*/
7+
export function PendingInputPlaceholder() {
8+
return (
9+
<Box
10+
aria-hidden
11+
className="w-full rounded-(--radius-2) border border-(--gray-5) bg-card opacity-70"
12+
>
13+
<Box className="min-h-[50px] px-2 py-2">
14+
<Box className="h-3 w-2/5 animate-pulse rounded bg-gray-4" />
15+
</Box>
16+
<Flex
17+
align="center"
18+
gap="2"
19+
className="border-(--gray-4) border-t px-2 py-1.5"
20+
>
21+
<Box className="h-5 w-5 animate-pulse rounded bg-gray-4" />
22+
<Box className="h-5 w-16 animate-pulse rounded bg-gray-4" />
23+
<Box className="h-5 w-20 animate-pulse rounded bg-gray-4" />
24+
<Box className="ml-auto h-6 w-6 animate-pulse rounded bg-gray-5" />
25+
</Flex>
26+
</Box>
27+
);
28+
}

apps/code/src/renderer/features/sessions/components/SessionView.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ import {
2929
isJsonRpcNotification,
3030
isJsonRpcResponse,
3131
} from "@shared/types/session-events";
32+
import {
33+
pendingTaskPromptStoreApi,
34+
usePendingTaskPrompt,
35+
} from "@stores/pendingTaskPromptStore";
3236
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3337
import { getSessionService } from "../service/service";
3438
import { flattenSelectOptions } from "../stores/sessionStore";
@@ -40,6 +44,7 @@ import { CloudInitializingView } from "./CloudInitializingView";
4044
import { ConversationView } from "./ConversationView";
4145
import { DropZoneOverlay } from "./DropZoneOverlay";
4246
import { ModelSelector } from "./ModelSelector";
47+
import { PendingChatView } from "./PendingChatView";
4348
import { PlanStatusBar } from "./PlanStatusBar";
4449
import { ReasoningLevelSelector } from "./ReasoningLevelSelector";
4550
import { RawLogsView } from "./raw-logs/RawLogsView";
@@ -128,6 +133,7 @@ export function SessionView({
128133
}: SessionViewProps) {
129134
const showRawLogs = useShowRawLogs();
130135
const { setShowRawLogs } = useSessionViewActions();
136+
const pendingTaskPrompt = usePendingTaskPrompt(taskId);
131137
const pendingPermissions = usePendingPermissionsForTask(taskId);
132138
const modeOption = useModeConfigOptionForTask(taskId);
133139
const thoughtOption = useThoughtLevelConfigOptionForTask(taskId);
@@ -138,6 +144,12 @@ export function SessionView({
138144
const handoffInProgress =
139145
useSessionForTask(taskId)?.handoffInProgress ?? false;
140146

147+
useEffect(() => {
148+
if (!taskId) return;
149+
if (isInitializing) return;
150+
pendingTaskPromptStoreApi.clear(taskId);
151+
}, [taskId, isInitializing]);
152+
141153
useEffect(() => {
142154
if (allowBypassPermissions) return;
143155
// Cloud runs execute in an isolated sandbox where bypass is safe, and the
@@ -512,6 +524,11 @@ export function SessionView({
512524
) : isInitializing ? (
513525
isCloud ? (
514526
<CloudInitializingView cloudStatus={cloudStatus} />
527+
) : pendingTaskPrompt?.promptText ? (
528+
<PendingChatView
529+
promptText={pendingTaskPrompt.promptText}
530+
attachments={pendingTaskPrompt.attachments}
531+
/>
515532
) : (
516533
<Flex
517534
align="center"

apps/code/src/renderer/features/sidebar/components/TaskListView.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,8 @@ export function TaskListView({
286286
(state) => state.navigateToTaskInput,
287287
);
288288
const isOnTaskInput = useNavigationStore(
289-
(state) => state.view.type === "task-input",
289+
(state) =>
290+
state.view.type === "task-input" || state.view.type === "task-pending",
290291
);
291292

292293
// biome-ignore lint/correctness/useExhaustiveDependencies: reset pagination when filters change

apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export interface SidebarData {
6565
interface ViewState {
6666
type:
6767
| "task-detail"
68+
| "task-pending"
6869
| "task-input"
6970
| "settings"
7071
| "folder-settings"
@@ -217,7 +218,8 @@ export function useSidebarData({
217218
const sortMode = useSidebarStore((state) => state.sortMode);
218219
const folderOrder = useSidebarStore((state) => state.folderOrder);
219220

220-
const isHomeActive = activeView.type === "task-input";
221+
const isHomeActive =
222+
activeView.type === "task-input" || activeView.type === "task-pending";
221223
const isInboxActive = activeView.type === "inbox";
222224
const isCommandCenterActive = activeView.type === "command-center";
223225
const isSkillsActive = activeView.type === "skills";
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { PendingChatView } from "@features/sessions/components/PendingChatView";
2+
import { Box } from "@radix-ui/themes";
3+
import { usePendingTaskPrompt } from "@stores/pendingTaskPromptStore";
4+
5+
interface TaskPendingViewProps {
6+
pendingTaskKey: string;
7+
}
8+
9+
export function TaskPendingView({ pendingTaskKey }: TaskPendingViewProps) {
10+
const pending = usePendingTaskPrompt(pendingTaskKey);
11+
12+
return (
13+
<Box className="relative h-full w-full bg-background">
14+
<PendingChatView
15+
promptText={pending?.promptText ?? ""}
16+
attachments={pending?.attachments}
17+
/>
18+
</Box>
19+
);
20+
}

apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { buildCloudTaskDescription } from "@features/editor/utils/cloud-prompt";
33
import { useTaskInputHistoryStore } from "@features/message-editor/stores/taskInputHistoryStore";
44
import type { EditorHandle } from "@features/message-editor/types";
55
import {
6+
contentToPlainText,
67
contentToXml,
78
type EditorContent,
89
extractFilePaths,
@@ -20,6 +21,7 @@ import { toast } from "@renderer/utils/toast";
2021
import type { ExecutionMode, Task } from "@shared/types";
2122
import { ANALYTICS_EVENTS } from "@shared/types/analytics";
2223
import { useNavigationStore } from "@stores/navigationStore";
24+
import { pendingTaskPromptStoreApi } from "@stores/pendingTaskPromptStore";
2325
import { track } from "@utils/analytics";
2426
import { logger } from "@utils/logger";
2527
import { useCallback, useState } from "react";
@@ -187,8 +189,12 @@ export function useTaskCreation({
187189
onTaskCreated,
188190
}: UseTaskCreationOptions): UseTaskCreationReturn {
189191
const [isCreatingTask, setIsCreatingTask] = useState(false);
190-
const { clearTaskInputReportAssociation, navigateToTask } =
191-
useNavigationStore();
192+
const {
193+
clearTaskInputReportAssociation,
194+
navigateToTask,
195+
navigateToPendingTask,
196+
navigateToTaskInput,
197+
} = useNavigationStore();
192198
const isAuthenticated = useAuthStateValue(
193199
(state) => state.status === "authenticated",
194200
);
@@ -210,11 +216,30 @@ export function useTaskCreation({
210216

211217
setIsCreatingTask(true);
212218

213-
try {
214-
const content = contentOverride ?? editor.getContent();
219+
const content = contentOverride ?? editor.getContent();
220+
const plainPromptText = contentToPlainText(content).trim();
221+
const shouldShowPendingView = !onTaskCreated && !!plainPromptText;
222+
const pendingTaskKey = shouldShowPendingView
223+
? (globalThis.crypto?.randomUUID?.() ?? `pending-${Date.now()}`)
224+
: null;
225+
226+
if (pendingTaskKey) {
227+
pendingTaskPromptStoreApi.set(pendingTaskKey, {
228+
promptText: plainPromptText,
229+
attachments: (content.attachments ?? []).map((a) => ({
230+
id: a.id,
231+
label: a.label,
232+
})),
233+
});
234+
navigateToPendingTask(pendingTaskKey);
235+
if (!contentOverride) {
236+
editor.clear();
237+
}
238+
}
215239

240+
try {
216241
if (!contentOverride) {
217-
const plainText = editor.getText()?.trim();
242+
const plainText = editor.getText()?.trim() ?? plainPromptText;
218243
if (plainText) {
219244
useTaskInputHistoryStore.getState().addPrompt(plainText);
220245
}
@@ -246,13 +271,16 @@ export function useTaskCreation({
246271
if (signalReportId) {
247272
clearTaskInputReportAssociation();
248273
}
274+
if (pendingTaskKey) {
275+
pendingTaskPromptStoreApi.move(pendingTaskKey, output.task.id);
276+
}
249277
if (onTaskCreated) {
250278
onTaskCreated(output.task);
251279
} else {
252280
navigateToTask(output.task);
253281
}
254282
useTourStore.getState().completeTour(createFirstTaskTour.id);
255-
if (!contentOverride) {
283+
if (!pendingTaskKey && !contentOverride) {
256284
editor.clear();
257285
}
258286
});
@@ -268,13 +296,21 @@ export function useTaskCreation({
268296
failedStep: result.failedStep,
269297
error: result.error,
270298
});
299+
if (pendingTaskKey) {
300+
pendingTaskPromptStoreApi.clear(pendingTaskKey);
301+
navigateToTaskInput({ initialPrompt: plainPromptText });
302+
}
271303
}
272304
return result.success;
273305
} catch (error) {
274306
const description =
275307
error instanceof Error ? error.message : "Unknown error";
276308
toast.error("Failed to create task", { description });
277309
log.error("Unexpected error during task creation", { error });
310+
if (pendingTaskKey) {
311+
pendingTaskPromptStoreApi.clear(pendingTaskKey);
312+
navigateToTaskInput({ initialPrompt: plainPromptText });
313+
}
278314
return false;
279315
} finally {
280316
setIsCreatingTask(false);
@@ -300,6 +336,8 @@ export function useTaskCreation({
300336
clearTaskInputReportAssociation,
301337
invalidateTasks,
302338
navigateToTask,
339+
navigateToPendingTask,
340+
navigateToTaskInput,
303341
onTaskCreated,
304342
],
305343
);

apps/code/src/renderer/stores/navigationStore.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,27 @@ describe("navigationStore", () => {
199199
type: "inbox",
200200
});
201201
});
202+
203+
it("navigates to pending task with key", () => {
204+
getStore().navigateToPendingTask("pending-key-123");
205+
expect(getView()).toMatchObject({
206+
type: "task-pending",
207+
pendingTaskKey: "pending-key-123",
208+
});
209+
});
210+
211+
it("replaces task-pending in history when navigating to real task", async () => {
212+
getStore().navigateToTaskInput();
213+
getStore().navigateToPendingTask("pending-key-123");
214+
const indexBeforeReal = getStore().history.length - 1;
215+
expect(getStore().history[indexBeforeReal].type).toBe("task-pending");
216+
217+
await getStore().navigateToTask(mockTask);
218+
219+
const finalHistory = getStore().history;
220+
expect(finalHistory[finalHistory.length - 1].type).toBe("task-detail");
221+
expect(finalHistory.some((v) => v.type === "task-pending")).toBe(false);
222+
});
202223
});
203224

204225
describe("history", () => {

0 commit comments

Comments
 (0)