Skip to content

Commit a3cf016

Browse files
authored
feat(task-input): show prompt in chat thread immediately on submit
Previously, submitting a new task left the user staring at the `TaskInput` form (with its submit button spinning) until the saga finished creating the task, registering the folder, and creating the workspace. Only then would `onTaskReady` fire and navigate to `TaskDetail`, where `SessionView` would show 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 of those steps blocked the UI from showing the user's 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 using 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. Tests: 2 new navigation-store tests cover `navigateToPendingTask` and the history-replace behavior; all 914 renderer tests pass. Generated-By: PostHog Code Task-Id: c34038da-f59d-4a38-8487-e5f3c6a1ef78
1 parent beed3a6 commit a3cf016

10 files changed

Lines changed: 237 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: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { CHAT_CONTENT_MAX_WIDTH } from "@features/sessions/constants";
2+
import { Spinner } from "@phosphor-icons/react";
3+
import { Box, Flex, Text } from "@radix-ui/themes";
4+
import { UserMessage } from "./session-update/UserMessage";
5+
6+
interface PendingChatViewProps {
7+
promptText: string;
8+
/** Render inside an existing positioned container — skip the absolute fill wrapper. */
9+
embedded?: boolean;
10+
}
11+
12+
export function PendingChatView({
13+
promptText,
14+
embedded = false,
15+
}: PendingChatViewProps) {
16+
const content = (
17+
<Flex direction="column" className="h-full w-full bg-background">
18+
<Box className="min-h-0 flex-1 overflow-y-auto">
19+
<Box
20+
className="mx-auto px-2 py-1.5"
21+
style={{ maxWidth: CHAT_CONTENT_MAX_WIDTH }}
22+
>
23+
<UserMessage content={promptText} animate={false} />
24+
</Box>
25+
</Box>
26+
<Box className="relative min-h-[66px] border-gray-4 border-t">
27+
<Flex
28+
align="center"
29+
justify="center"
30+
gap="2"
31+
className="absolute inset-0"
32+
>
33+
<Spinner size={28} className="animate-spin text-gray-9" />
34+
<Text color="gray" className="text-base">
35+
Connecting to agent...
36+
</Text>
37+
</Flex>
38+
</Box>
39+
</Flex>
40+
);
41+
42+
if (embedded) {
43+
return <Box className="absolute inset-0">{content}</Box>;
44+
}
45+
46+
return content;
47+
}

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+
embedded
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
@@ -273,7 +273,8 @@ export function TaskListView({
273273
(state) => state.navigateToTaskInput,
274274
);
275275
const isOnTaskInput = useNavigationStore(
276-
(state) => state.view.type === "task-input",
276+
(state) =>
277+
state.view.type === "task-input" || state.view.type === "task-pending",
277278
);
278279

279280
// 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: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { PendingChatView } from "@features/sessions/components/PendingChatView";
2+
import { Flex } 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+
<Flex direction="column" height="100%" className="relative bg-background">
14+
<PendingChatView promptText={pending?.promptText ?? ""} />
15+
</Flex>
16+
);
17+
}

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

Lines changed: 42 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,28 @@ 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+
attachmentLabels: (content.attachments ?? []).map((a) => a.label),
230+
createdAt: Date.now(),
231+
});
232+
navigateToPendingTask(pendingTaskKey);
233+
if (!contentOverride) {
234+
editor.clear();
235+
}
236+
}
215237

238+
try {
216239
if (!contentOverride) {
217-
const plainText = editor.getText()?.trim();
240+
const plainText = editor.getText()?.trim() ?? plainPromptText;
218241
if (plainText) {
219242
useTaskInputHistoryStore.getState().addPrompt(plainText);
220243
}
@@ -246,13 +269,16 @@ export function useTaskCreation({
246269
if (signalReportId) {
247270
clearTaskInputReportAssociation();
248271
}
272+
if (pendingTaskKey) {
273+
pendingTaskPromptStoreApi.move(pendingTaskKey, output.task.id);
274+
}
249275
if (onTaskCreated) {
250276
onTaskCreated(output.task);
251277
} else {
252278
navigateToTask(output.task);
253279
}
254280
useTourStore.getState().completeTour(createFirstTaskTour.id);
255-
if (!contentOverride) {
281+
if (!pendingTaskKey && !contentOverride) {
256282
editor.clear();
257283
}
258284
});
@@ -268,13 +294,21 @@ export function useTaskCreation({
268294
failedStep: result.failedStep,
269295
error: result.error,
270296
});
297+
if (pendingTaskKey) {
298+
pendingTaskPromptStoreApi.clear(pendingTaskKey);
299+
navigateToTaskInput({ initialPrompt: plainPromptText });
300+
}
271301
}
272302
return result.success;
273303
} catch (error) {
274304
const description =
275305
error instanceof Error ? error.message : "Unknown error";
276306
toast.error("Failed to create task", { description });
277307
log.error("Unexpected error during task creation", { error });
308+
if (pendingTaskKey) {
309+
pendingTaskPromptStoreApi.clear(pendingTaskKey);
310+
navigateToTaskInput({ initialPrompt: plainPromptText });
311+
}
278312
return false;
279313
} finally {
280314
setIsCreatingTask(false);
@@ -300,6 +334,8 @@ export function useTaskCreation({
300334
clearTaskInputReportAssociation,
301335
invalidateTasks,
302336
navigateToTask,
337+
navigateToPendingTask,
338+
navigateToTaskInput,
303339
onTaskCreated,
304340
],
305341
);

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", () => {

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

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const log = logger.scope("navigation-store");
1414

1515
type ViewType =
1616
| "task-detail"
17+
| "task-pending"
1718
| "task-input"
1819
| "folder-settings"
1920
| "inbox"
@@ -43,6 +44,7 @@ interface ViewState {
4344
initialPrompt?: string;
4445
initialCloudRepository?: string;
4546
reportAssociation?: TaskInputReportAssociation;
47+
pendingTaskKey?: string;
4648
}
4749

4850
interface NavigationStore {
@@ -52,6 +54,7 @@ interface NavigationStore {
5254
taskInputReportAssociation?: TaskInputReportAssociation;
5355
taskInputCloudRepository?: string;
5456
navigateToTask: (task: Task) => void;
57+
navigateToPendingTask: (pendingTaskKey: string) => void;
5558
navigateToTaskInput: (
5659
folderIdOrOptions?: string | TaskInputNavigationOptions,
5760
) => void;
@@ -74,6 +77,9 @@ const isSameView = (view1: ViewState, view2: ViewState): boolean => {
7477
if (view1.type === "task-detail" && view2.type === "task-detail") {
7578
return view1.data?.id === view2.data?.id;
7679
}
80+
if (view1.type === "task-pending" && view2.type === "task-pending") {
81+
return view1.pendingTaskKey === view2.pendingTaskKey;
82+
}
7783
if (view1.type === "task-input" && view2.type === "task-input") {
7884
return (
7985
view1.folderId === view2.folderId &&
@@ -109,7 +115,14 @@ export const useNavigationStore = create<NavigationStore>()(
109115
if (isSameView(view, newView)) {
110116
return;
111117
}
112-
const newHistory = [...history.slice(0, historyIndex + 1), newView];
118+
// Replace transient task-pending entries instead of stacking them in
119+
// history — going back to a pending view after the real task lands
120+
// would render an empty placeholder.
121+
const baseHistory =
122+
view.type === "task-pending"
123+
? history.slice(0, historyIndex)
124+
: history.slice(0, historyIndex + 1);
125+
const newHistory = [...baseHistory, newView];
113126
set({
114127
view: newView,
115128
history: newHistory,
@@ -186,6 +199,10 @@ export const useNavigationStore = create<NavigationStore>()(
186199
}
187200
},
188201

202+
navigateToPendingTask: (pendingTaskKey: string) => {
203+
navigate({ type: "task-pending", pendingTaskKey });
204+
},
205+
189206
navigateToTaskInput: (folderIdOrOptions) => {
190207
const options =
191208
typeof folderIdOrOptions === "string"
@@ -326,11 +343,14 @@ export const useNavigationStore = create<NavigationStore>()(
326343
name: "navigation-storage",
327344
storage: electronStorage,
328345
partialize: (state) => ({
329-
view: {
330-
type: state.view.type,
331-
taskId: state.view.taskId,
332-
folderId: state.view.folderId,
333-
},
346+
view:
347+
state.view.type === "task-pending"
348+
? { type: "task-input" as const }
349+
: {
350+
type: state.view.type,
351+
taskId: state.view.taskId,
352+
folderId: state.view.folderId,
353+
},
334354
}),
335355
},
336356
),

0 commit comments

Comments
 (0)