Skip to content

Commit 2eef20d

Browse files
committed
feat: agent chat
1 parent 3bc5d58 commit 2eef20d

File tree

18 files changed

+1796
-24
lines changed

18 files changed

+1796
-24
lines changed

apps/array/src/main/preload.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,10 @@ contextBridge.exposeInMainWorld("electronAPI", {
187187
sdkSessionId?: string;
188188
}): Promise<{ sessionId: string; channel: string } | null> =>
189189
ipcRenderer.invoke("agent-reconnect", params),
190+
agentSetSessionMode: async (
191+
sessionId: string,
192+
modeId: string,
193+
): Promise<void> => ipcRenderer.invoke("agent-set-session-mode", sessionId, modeId),
190194
onAgentEvent: (
191195
channel: string,
192196
listener: (payload: unknown) => void,

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,24 @@ export class SessionManager {
302302
}
303303
}
304304

305+
async setSessionMode(taskRunId: string, modeId: string): Promise<void> {
306+
const session = this.sessions.get(taskRunId);
307+
if (!session) {
308+
throw new Error(`Session not found: ${taskRunId}`);
309+
}
310+
311+
try {
312+
await session.connection.setSessionMode({
313+
sessionId: taskRunId,
314+
modeId,
315+
});
316+
log.info("Session mode changed", { taskRunId, modeId });
317+
} catch (err) {
318+
log.error("Failed to set session mode", { taskRunId, modeId, err });
319+
throw err;
320+
}
321+
}
322+
305323
getSession(taskRunId: string): ManagedSession | undefined {
306324
return this.sessions.get(taskRunId);
307325
}
@@ -588,4 +606,15 @@ export function registerAgentIpc(
588606
return session ? toSessionResponse(session) : null;
589607
},
590608
);
609+
610+
ipcMain.handle(
611+
"agent-set-session-mode",
612+
async (
613+
_event: IpcMainInvokeEvent,
614+
sessionId: string,
615+
modeId: string,
616+
): Promise<void> => {
617+
return sessionManager.setSessionMode(sessionId, modeId);
618+
},
619+
);
591620
}

apps/array/src/renderer/features/sessions/components/MessageEditor.tsx

Lines changed: 37 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import { ArrowUp, Paperclip, Stop } from "@phosphor-icons/react";
33
import { Box, Flex, IconButton, Tooltip } from "@radix-ui/themes";
44
import { logger } from "@renderer/lib/logger";
55
import type { MentionItem } from "@shared/types";
6+
import {
7+
type SessionMode,
8+
SessionModeSwitcher,
9+
} from "./SessionModeSwitcher";
610
import { Extension, type JSONContent } from "@tiptap/core";
711
import { Mention } from "@tiptap/extension-mention";
812
import { Placeholder } from "@tiptap/extension-placeholder";
@@ -182,6 +186,8 @@ interface MessageEditorProps {
182186
onCancel?: () => void;
183187
onAttachFiles?: (files: File[]) => void;
184188
autoFocus?: boolean;
189+
currentMode?: SessionMode;
190+
onModeChange?: (mode: SessionMode) => void;
185191
}
186192

187193
export const MessageEditor = forwardRef<
@@ -199,6 +205,8 @@ export const MessageEditor = forwardRef<
199205
onCancel,
200206
onAttachFiles,
201207
autoFocus = false,
208+
currentMode,
209+
onModeChange,
202210
},
203211
ref,
204212
) => {
@@ -434,26 +442,35 @@ export const MessageEditor = forwardRef<
434442
<EditorContent editor={editor} />
435443
</Box>
436444
<Flex justify="between" align="center">
437-
<input
438-
ref={fileInputRef}
439-
type="file"
440-
multiple
441-
onChange={handleFileSelect}
442-
style={{ display: "none" }}
443-
/>
444-
<Tooltip content="Attach file">
445-
<IconButton
446-
size="1"
447-
variant="ghost"
448-
color="gray"
449-
onClick={() => fileInputRef.current?.click()}
450-
disabled={disabled}
451-
title="Attach file"
452-
style={{ marginLeft: "0px" }}
453-
>
454-
<Paperclip size={14} weight="bold" />
455-
</IconButton>
456-
</Tooltip>
445+
<Flex gap="1" align="center">
446+
<input
447+
ref={fileInputRef}
448+
type="file"
449+
multiple
450+
onChange={handleFileSelect}
451+
style={{ display: "none" }}
452+
/>
453+
<Tooltip content="Attach file">
454+
<IconButton
455+
size="1"
456+
variant="ghost"
457+
color="gray"
458+
onClick={() => fileInputRef.current?.click()}
459+
disabled={disabled}
460+
title="Attach file"
461+
style={{ marginLeft: "0px" }}
462+
>
463+
<Paperclip size={14} weight="bold" />
464+
</IconButton>
465+
</Tooltip>
466+
{currentMode && onModeChange && (
467+
<SessionModeSwitcher
468+
value={currentMode}
469+
onChange={onModeChange}
470+
disabled={disabled}
471+
/>
472+
)}
473+
</Flex>
457474
<Flex gap="4" align="center">
458475
{isLoading && onCancel ? (
459476
<Tooltip content="Stop">
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import {
2+
ChatCircle,
3+
CheckCircle,
4+
Lightbulb,
5+
ShieldSlash,
6+
} from "@phosphor-icons/react";
7+
import { ChevronDownIcon } from "@radix-ui/react-icons";
8+
import { Button, DropdownMenu, Flex, Text, Tooltip } from "@radix-ui/themes";
9+
10+
export type SessionMode =
11+
| "default"
12+
| "acceptEdits"
13+
| "plan"
14+
| "bypassPermissions";
15+
16+
interface SessionModeSwitcherProps {
17+
value: SessionMode;
18+
onChange: (mode: SessionMode) => void;
19+
disabled?: boolean;
20+
}
21+
22+
const MODE_CONFIG: Record<
23+
SessionMode,
24+
{
25+
label: string;
26+
shortLabel: string;
27+
icon: React.ReactNode;
28+
description: string;
29+
}
30+
> = {
31+
default: {
32+
label: "Always Ask",
33+
shortLabel: "Ask",
34+
icon: <ChatCircle size={14} weight="regular" />,
35+
description: "Prompts for permission on first use of each tool",
36+
},
37+
acceptEdits: {
38+
label: "Accept Edits",
39+
shortLabel: "Edits",
40+
icon: <CheckCircle size={14} weight="regular" />,
41+
description: "Automatically accepts file edit permissions",
42+
},
43+
plan: {
44+
label: "Plan Mode",
45+
shortLabel: "Plan",
46+
icon: <Lightbulb size={14} weight="regular" />,
47+
description: "Analyze but don't modify files or execute commands",
48+
},
49+
bypassPermissions: {
50+
label: "Bypass Permissions",
51+
shortLabel: "Bypass",
52+
icon: <ShieldSlash size={14} weight="regular" />,
53+
description: "Skips all permission prompts",
54+
},
55+
};
56+
57+
export function SessionModeSwitcher({
58+
value,
59+
onChange,
60+
disabled = false,
61+
}: SessionModeSwitcherProps) {
62+
const currentConfig = MODE_CONFIG[value];
63+
64+
return (
65+
<DropdownMenu.Root>
66+
<Tooltip content={currentConfig.description}>
67+
<DropdownMenu.Trigger disabled={disabled}>
68+
<Button
69+
color="gray"
70+
variant="ghost"
71+
size="1"
72+
disabled={disabled}
73+
style={{ gap: "4px", padding: "0 6px" }}
74+
>
75+
{currentConfig.icon}
76+
<Text size="1" style={{ fontWeight: 400 }}>
77+
{currentConfig.shortLabel}
78+
</Text>
79+
<ChevronDownIcon style={{ flexShrink: 0, opacity: 0.6 }} />
80+
</Button>
81+
</DropdownMenu.Trigger>
82+
</Tooltip>
83+
84+
<DropdownMenu.Content align="start" size="1">
85+
{Object.entries(MODE_CONFIG).map(([modeKey, config]) => (
86+
<DropdownMenu.Item
87+
key={modeKey}
88+
onSelect={() => onChange(modeKey as SessionMode)}
89+
>
90+
<Flex direction="column" gap="1">
91+
<Flex align="center" gap="2">
92+
{config.icon}
93+
<Text size="1" weight={value === modeKey ? "medium" : "regular"}>
94+
{config.label}
95+
</Text>
96+
</Flex>
97+
<Text
98+
size="1"
99+
color="gray"
100+
style={{ paddingLeft: "22px", lineHeight: 1.3 }}
101+
>
102+
{config.description}
103+
</Text>
104+
</Flex>
105+
</DropdownMenu.Item>
106+
))}
107+
</DropdownMenu.Content>
108+
</DropdownMenu.Root>
109+
);
110+
}

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

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import {
1010
Text,
1111
TextField,
1212
} from "@radix-ui/themes";
13-
import { useCallback, useEffect, useMemo, useRef } from "react";
13+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
14+
import { logger } from "@renderer/lib/logger";
15+
import type { SessionMode } from "./SessionModeSwitcher";
1416
import type { SessionEvent } from "../stores/sessionStore";
1517
import { useSessionViewStore } from "../stores/sessionViewStore";
1618
import { AgentMessage } from "./AgentMessage";
@@ -339,6 +341,8 @@ function groupMessagesIntoTurns(
339341
return turns;
340342
}
341343

344+
const log = logger.scope("session-view");
345+
342346
export function SessionView({
343347
events,
344348
sessionId,
@@ -349,6 +353,35 @@ export function SessionView({
349353
repoPath,
350354
}: SessionViewProps) {
351355
const searchInputRef = useRef<HTMLInputElement>(null);
356+
const [currentMode, setCurrentMode] = useState<SessionMode>("default");
357+
358+
const handleModeChange = useCallback(
359+
async (mode: SessionMode) => {
360+
if (!sessionId) return;
361+
setCurrentMode(mode);
362+
try {
363+
await window.electronAPI.agentSetSessionMode(sessionId, mode);
364+
log.info("Session mode changed", { sessionId, mode });
365+
} catch (error) {
366+
log.error("Failed to change session mode", { sessionId, mode, error });
367+
setCurrentMode(currentMode);
368+
}
369+
},
370+
[sessionId, currentMode],
371+
);
372+
373+
useEffect(() => {
374+
for (const event of events) {
375+
if (event.type !== "session_update") continue;
376+
const update = event.notification?.update;
377+
if (update?.sessionUpdate === "current_mode_update" && "currentModeId" in update) {
378+
const newMode = update.currentModeId as SessionMode;
379+
if (newMode !== currentMode) {
380+
setCurrentMode(newMode);
381+
}
382+
}
383+
}
384+
}, [events, currentMode]);
352385

353386
const {
354387
showRawLogs,
@@ -621,6 +654,8 @@ export function SessionView({
621654
isLoading={isPromptPending}
622655
onSubmit={handleSubmit}
623656
onCancel={onCancelPrompt}
657+
currentMode={currentMode}
658+
onModeChange={handleModeChange}
624659
/>
625660
</Box>
626661
</Flex>

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ declare global {
138138
logUrl?: string;
139139
sdkSessionId?: string;
140140
}) => Promise<{ sessionId: string; channel: string } | null>;
141+
agentSetSessionMode: (sessionId: string, modeId: string) => Promise<void>;
141142
onAgentEvent: (
142143
channel: string,
143144
listener: (event: unknown) => void,

0 commit comments

Comments
 (0)