Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/array/src/main/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
ipcRenderer.invoke("agent-prompt", sessionId, prompt),
agentCancel: async (sessionId: string): Promise<boolean> =>
ipcRenderer.invoke("agent-cancel", sessionId),
agentCancelPrompt: async (sessionId: string): Promise<boolean> =>
ipcRenderer.invoke("agent-cancel-prompt", sessionId),
agentListSessions: async (
taskId?: string,
): Promise<
Expand Down
20 changes: 20 additions & 0 deletions apps/array/src/main/services/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,19 @@ export class SessionManager {
}
}

async cancelPrompt(taskRunId: string): Promise<boolean> {
const session = this.sessions.get(taskRunId);
if (!session) return false;

try {
await session.connection.cancel({ sessionId: taskRunId });
return true;
} catch (err) {
log.error("Failed to cancel prompt", { taskRunId, err });
return false;
}
}

getSession(taskRunId: string): ManagedSession | undefined {
return this.sessions.get(taskRunId);
}
Expand Down Expand Up @@ -523,6 +536,13 @@ export function registerAgentIpc(
},
);

ipcMain.handle(
"agent-cancel-prompt",
async (_event: IpcMainInvokeEvent, sessionId: string) => {
return sessionManager.cancelPrompt(sessionId);
},
);

ipcMain.handle(
"agent-list-sessions",
async (
Expand Down
39 changes: 28 additions & 11 deletions apps/array/src/renderer/features/logs/components/LogView.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import type { SessionNotification } from "@agentclientprotocol/sdk";
import type { SessionEvent } from "@features/sessions/stores/sessionStore";
import { useAutoScroll } from "@hooks/useAutoScroll";
import { PaperPlaneRight as SendIcon } from "@phosphor-icons/react";
import {
PaperPlaneRight as SendIcon,
Stop as StopIcon,
} from "@phosphor-icons/react";
import {
Box,
Button,
Expand All @@ -21,6 +24,7 @@ interface LogViewProps {
isRunning: boolean;
isPromptPending?: boolean;
onSendPrompt?: (text: string) => Promise<void>;
onCancelPrompt?: () => void;
onStartSession?: () => void;
}

Expand Down Expand Up @@ -56,6 +60,7 @@ export function LogView({
isRunning,
isPromptPending = false,
onSendPrompt,
onCancelPrompt,
onStartSession,
}: LogViewProps) {
const [inputValue, setInputValue] = useState("");
Expand Down Expand Up @@ -86,8 +91,12 @@ export function LogView({
e.preventDefault();
handleSend();
}
if (e.key === "Escape" && isPromptPending && onCancelPrompt) {
e.preventDefault();
onCancelPrompt();
}
},
[handleSend],
[handleSend, isPromptPending, onCancelPrompt],
);

// Build rendered output from events (filter out raw acp_message unless showRawLogs is true)
Expand Down Expand Up @@ -205,15 +214,23 @@ export function LogView({
style={{ resize: "none" }}
/>
</Box>
<Tooltip content="Send message (Enter)">
<IconButton
size="3"
onClick={handleSend}
disabled={!inputValue.trim() || isPromptPending || !isRunning}
>
<SendIcon size={20} />
</IconButton>
</Tooltip>
{isPromptPending ? (
<Tooltip content="Cancel (Esc)">
<IconButton size="3" color="red" onClick={onCancelPrompt}>
<StopIcon size={20} weight="fill" />
</IconButton>
</Tooltip>
) : (
<Tooltip content="Send message (Enter)">
<IconButton
size="3"
onClick={handleSend}
disabled={!inputValue.trim() || !isRunning}
>
<SendIcon size={20} />
</IconButton>
</Tooltip>
)}
</Flex>
</Box>
)}
Expand Down
15 changes: 15 additions & 0 deletions apps/array/src/renderer/features/sessions/stores/sessionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ interface SessionStore {
prompt: string | ContentBlock[],
) => Promise<{ stopReason: string }>;

// Cancel ongoing prompt without terminating session
cancelPrompt: (taskId: string) => Promise<boolean>;

// Internal: subscribe to IPC events
_subscribeToChannel: (
taskRunId: string,
Expand Down Expand Up @@ -335,6 +338,18 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
}
},

cancelPrompt: async (taskId) => {
const session = get().getSessionForTask(taskId);
if (!session) return false;

try {
return await window.electronAPI.agentCancelPrompt(session.taskRunId);
} catch (error) {
log.error("Failed to cancel prompt", error);
return false;
}
},

_subscribeToChannel: (taskRunId, _taskId, channel) => {
if (subscriptions.has(taskRunId)) {
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export function TaskLogsPanel({ taskId, task }: TaskLogsPanelProps) {
const session = useSessionStore((state) => state.getSessionForTask(taskId));
const connectToTask = useSessionStore((state) => state.connectToTask);
const sendPrompt = useSessionStore((state) => state.sendPrompt);
const cancelPrompt = useSessionStore((state) => state.cancelPrompt);

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

const handleCancelPrompt = useCallback(async () => {
const result = await cancelPrompt(taskId);
log.info("Prompt cancelled", { success: result });
}, [taskId, cancelPrompt]);

return (
<BackgroundWrapper>
<Box height="100%" width="100%">
Expand All @@ -84,6 +90,7 @@ export function TaskLogsPanel({ taskId, task }: TaskLogsPanelProps) {
isRunning={isRunning}
isPromptPending={session?.isPromptPending}
onSendPrompt={handleSendPrompt}
onCancelPrompt={handleCancelPrompt}
onStartSession={handleStartSession}
/>
</Box>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { RenameTaskDialog } from "@components/RenameTaskDialog";
import { useTaskStore } from "@features/tasks/stores/taskStore";
import { useTaskContextMenu } from "@hooks/useTaskContextMenu";
import { GitPullRequest } from "@phosphor-icons/react";
import { Cloud, GitPullRequest } from "@phosphor-icons/react";
import { Badge, Box, Code, Flex, Text } from "@radix-ui/themes";
import type { Task } from "@shared/types";
import { differenceInHours, format, formatDistanceToNow } from "date-fns";
Expand Down Expand Up @@ -42,6 +42,7 @@ function TaskItemComponent({
const prUrl = task.latest_run?.output?.pr_url as string | undefined;
const hasPR = !!prUrl;
const status = hasPR ? "completed" : task.latest_run?.status || "backlog";
const isCloudTask = task.latest_run?.environment === "cloud";

const handleOpenPR = (e: React.MouseEvent) => {
e.stopPropagation();
Expand Down Expand Up @@ -128,6 +129,18 @@ function TaskItemComponent({
>
{task.title}
</Text>
{isCloudTask && (
<Flex
align="center"
gap="1"
style={{ flexShrink: 0, opacity: 0.7 }}
>
<Cloud size={12} className="text-gray-10" />
<Text size="1" color="gray">
Cloud
</Text>
</Flex>
)}
{worktreeName && (
<Text
size="1"
Expand Down
1 change: 1 addition & 0 deletions apps/array/src/renderer/types/electron.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ declare global {
prompt: ContentBlock[],
) => Promise<{ stopReason: string }>;
agentCancel: (sessionId: string) => Promise<boolean>;
agentCancelPrompt: (sessionId: string) => Promise<boolean>;
agentListSessions: (taskId?: string) => Promise<
Array<{
sessionId: string;
Expand Down