Skip to content

Commit 817d356

Browse files
authored
feat: allow client to cancel prompt (#187)
1 parent 8eb718b commit 817d356

File tree

7 files changed

+87
-12
lines changed

7 files changed

+87
-12
lines changed

apps/array/src/main/preload.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
163163
ipcRenderer.invoke("agent-prompt", sessionId, prompt),
164164
agentCancel: async (sessionId: string): Promise<boolean> =>
165165
ipcRenderer.invoke("agent-cancel", sessionId),
166+
agentCancelPrompt: async (sessionId: string): Promise<boolean> =>
167+
ipcRenderer.invoke("agent-cancel-prompt", sessionId),
166168
agentListSessions: async (
167169
taskId?: string,
168170
): Promise<

apps/array/src/main/services/session-manager.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,19 @@ export class SessionManager {
289289
}
290290
}
291291

292+
async cancelPrompt(taskRunId: string): Promise<boolean> {
293+
const session = this.sessions.get(taskRunId);
294+
if (!session) return false;
295+
296+
try {
297+
await session.connection.cancel({ sessionId: taskRunId });
298+
return true;
299+
} catch (err) {
300+
log.error("Failed to cancel prompt", { taskRunId, err });
301+
return false;
302+
}
303+
}
304+
292305
getSession(taskRunId: string): ManagedSession | undefined {
293306
return this.sessions.get(taskRunId);
294307
}
@@ -523,6 +536,13 @@ export function registerAgentIpc(
523536
},
524537
);
525538

539+
ipcMain.handle(
540+
"agent-cancel-prompt",
541+
async (_event: IpcMainInvokeEvent, sessionId: string) => {
542+
return sessionManager.cancelPrompt(sessionId);
543+
},
544+
);
545+
526546
ipcMain.handle(
527547
"agent-list-sessions",
528548
async (

apps/array/src/renderer/features/logs/components/LogView.tsx

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import type { SessionNotification } from "@agentclientprotocol/sdk";
22
import type { SessionEvent } from "@features/sessions/stores/sessionStore";
33
import { useAutoScroll } from "@hooks/useAutoScroll";
4-
import { PaperPlaneRight as SendIcon } from "@phosphor-icons/react";
4+
import {
5+
PaperPlaneRight as SendIcon,
6+
Stop as StopIcon,
7+
} from "@phosphor-icons/react";
58
import {
69
Box,
710
Button,
@@ -21,6 +24,7 @@ interface LogViewProps {
2124
isRunning: boolean;
2225
isPromptPending?: boolean;
2326
onSendPrompt?: (text: string) => Promise<void>;
27+
onCancelPrompt?: () => void;
2428
onStartSession?: () => void;
2529
}
2630

@@ -56,6 +60,7 @@ export function LogView({
5660
isRunning,
5761
isPromptPending = false,
5862
onSendPrompt,
63+
onCancelPrompt,
5964
onStartSession,
6065
}: LogViewProps) {
6166
const [inputValue, setInputValue] = useState("");
@@ -86,8 +91,12 @@ export function LogView({
8691
e.preventDefault();
8792
handleSend();
8893
}
94+
if (e.key === "Escape" && isPromptPending && onCancelPrompt) {
95+
e.preventDefault();
96+
onCancelPrompt();
97+
}
8998
},
90-
[handleSend],
99+
[handleSend, isPromptPending, onCancelPrompt],
91100
);
92101

93102
// Build rendered output from events (filter out raw acp_message unless showRawLogs is true)
@@ -205,15 +214,23 @@ export function LogView({
205214
style={{ resize: "none" }}
206215
/>
207216
</Box>
208-
<Tooltip content="Send message (Enter)">
209-
<IconButton
210-
size="3"
211-
onClick={handleSend}
212-
disabled={!inputValue.trim() || isPromptPending || !isRunning}
213-
>
214-
<SendIcon size={20} />
215-
</IconButton>
216-
</Tooltip>
217+
{isPromptPending ? (
218+
<Tooltip content="Cancel (Esc)">
219+
<IconButton size="3" color="red" onClick={onCancelPrompt}>
220+
<StopIcon size={20} weight="fill" />
221+
</IconButton>
222+
</Tooltip>
223+
) : (
224+
<Tooltip content="Send message (Enter)">
225+
<IconButton
226+
size="3"
227+
onClick={handleSend}
228+
disabled={!inputValue.trim() || !isRunning}
229+
>
230+
<SendIcon size={20} />
231+
</IconButton>
232+
</Tooltip>
233+
)}
217234
</Flex>
218235
</Box>
219236
)}

apps/array/src/renderer/features/sessions/stores/sessionStore.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ interface SessionStore {
6262
prompt: string | ContentBlock[],
6363
) => Promise<{ stopReason: string }>;
6464

65+
// Cancel ongoing prompt without terminating session
66+
cancelPrompt: (taskId: string) => Promise<boolean>;
67+
6568
// Internal: subscribe to IPC events
6669
_subscribeToChannel: (
6770
taskRunId: string,
@@ -335,6 +338,18 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
335338
}
336339
},
337340

341+
cancelPrompt: async (taskId) => {
342+
const session = get().getSessionForTask(taskId);
343+
if (!session) return false;
344+
345+
try {
346+
return await window.electronAPI.agentCancelPrompt(session.taskRunId);
347+
} catch (error) {
348+
log.error("Failed to cancel prompt", error);
349+
return false;
350+
}
351+
},
352+
338353
_subscribeToChannel: (taskRunId, _taskId, channel) => {
339354
if (subscriptions.has(taskRunId)) {
340355
return;

apps/array/src/renderer/features/task-detail/components/TaskLogsPanel.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export function TaskLogsPanel({ taskId, task }: TaskLogsPanelProps) {
2727
const session = useSessionStore((state) => state.getSessionForTask(taskId));
2828
const connectToTask = useSessionStore((state) => state.connectToTask);
2929
const sendPrompt = useSessionStore((state) => state.sendPrompt);
30+
const cancelPrompt = useSessionStore((state) => state.cancelPrompt);
3031

3132
const isRunning =
3233
session?.status === "connected" || session?.status === "connecting";
@@ -75,6 +76,11 @@ export function TaskLogsPanel({ taskId, task }: TaskLogsPanelProps) {
7576
[taskId, sendPrompt],
7677
);
7778

79+
const handleCancelPrompt = useCallback(async () => {
80+
const result = await cancelPrompt(taskId);
81+
log.info("Prompt cancelled", { success: result });
82+
}, [taskId, cancelPrompt]);
83+
7884
return (
7985
<BackgroundWrapper>
8086
<Box height="100%" width="100%">
@@ -84,6 +90,7 @@ export function TaskLogsPanel({ taskId, task }: TaskLogsPanelProps) {
8490
isRunning={isRunning}
8591
isPromptPending={session?.isPromptPending}
8692
onSendPrompt={handleSendPrompt}
93+
onCancelPrompt={handleCancelPrompt}
8794
onStartSession={handleStartSession}
8895
/>
8996
</Box>

apps/array/src/renderer/features/task-list/components/TaskItem.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { RenameTaskDialog } from "@components/RenameTaskDialog";
22
import { useTaskStore } from "@features/tasks/stores/taskStore";
33
import { useTaskContextMenu } from "@hooks/useTaskContextMenu";
4-
import { GitPullRequest } from "@phosphor-icons/react";
4+
import { Cloud, GitPullRequest } from "@phosphor-icons/react";
55
import { Badge, Box, Code, Flex, Text } from "@radix-ui/themes";
66
import type { Task } from "@shared/types";
77
import { differenceInHours, format, formatDistanceToNow } from "date-fns";
@@ -42,6 +42,7 @@ function TaskItemComponent({
4242
const prUrl = task.latest_run?.output?.pr_url as string | undefined;
4343
const hasPR = !!prUrl;
4444
const status = hasPR ? "completed" : task.latest_run?.status || "backlog";
45+
const isCloudTask = task.latest_run?.environment === "cloud";
4546

4647
const handleOpenPR = (e: React.MouseEvent) => {
4748
e.stopPropagation();
@@ -128,6 +129,18 @@ function TaskItemComponent({
128129
>
129130
{task.title}
130131
</Text>
132+
{isCloudTask && (
133+
<Flex
134+
align="center"
135+
gap="1"
136+
style={{ flexShrink: 0, opacity: 0.7 }}
137+
>
138+
<Cloud size={12} className="text-gray-10" />
139+
<Text size="1" color="gray">
140+
Cloud
141+
</Text>
142+
</Flex>
143+
)}
131144
{worktreeName && (
132145
<Text
133146
size="1"

apps/array/src/renderer/types/electron.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ declare global {
119119
prompt: ContentBlock[],
120120
) => Promise<{ stopReason: string }>;
121121
agentCancel: (sessionId: string) => Promise<boolean>;
122+
agentCancelPrompt: (sessionId: string) => Promise<boolean>;
122123
agentListSessions: (taskId?: string) => Promise<
123124
Array<{
124125
sessionId: string;

0 commit comments

Comments
 (0)