Skip to content
Open
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/code/src/main/services/agent/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ function createMockDependencies() {
register: vi.fn(),
unregister: vi.fn(),
killByTaskId: vi.fn(),
getByTaskId: vi.fn(() => []),
kill: vi.fn(),
},
sleepService: {
acquire: vi.fn(),
Expand Down
7 changes: 5 additions & 2 deletions apps/code/src/main/services/agent/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -623,8 +623,11 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
return existing;
}

// Kill any lingering processes from previous runs of this task
this.processTracking.killByTaskId(taskId);
for (const proc of this.processTracking.getByTaskId(taskId)) {
if (proc.category === "agent" || proc.category === "child") {
this.processTracking.kill(proc.pid);
}
}

// Clean up any prior session for this taskRunId before creating a new one
await this.cleanupSession(taskRunId);
Expand Down
7 changes: 7 additions & 0 deletions apps/code/src/main/services/shell/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ export const createInput = sessionIdInput.extend({
taskId: z.string().optional(),
});

export const createCommandInput = sessionIdInput.extend({
command: z.string().min(1),
cwd: z.string(),
taskId: z.string().optional(),
});

export const writeInput = sessionIdInput.extend({
data: z.string(),
});
Expand All @@ -31,6 +37,7 @@ export const executeOutput = z.object({

export type SessionIdInput = z.infer<typeof sessionIdInput>;
export type CreateInput = z.infer<typeof createInput>;
export type CreateCommandInput = z.infer<typeof createCommandInput>;
export type WriteInput = z.infer<typeof writeInput>;
export type ResizeInput = z.infer<typeof resizeInput>;
export type ExecuteInput = z.infer<typeof executeInput>;
Expand Down
78 changes: 78 additions & 0 deletions apps/code/src/main/services/shell/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,84 @@ export class ShellService extends TypedEventEmitter<ShellEvents> {
return session;
}

async createCommandSession(options: {
sessionId: string;
command: string;
cwd: string;
taskId?: string;
}): Promise<void> {
const { sessionId, command, cwd, taskId } = options;

const existing = this.sessions.get(sessionId);
if (existing) {
return;
}

const taskEnv = await this.getTaskEnv(taskId);
const workingDir = this.resolveWorkingDir(sessionId, cwd);
const shell = getDefaultShell();

log.info(
`Creating command session ${sessionId}: shell=${shell} -c ..., cwd=${workingDir}`,
);

const ptyProcess = pty.spawn(shell, ["-c", command], {
name: "xterm-256color",
cols: 80,
rows: 24,
cwd: workingDir,
env: buildShellEnv(taskEnv),
encoding: null,
});

this.processTracking.register(
ptyProcess.pid,
"shell",
`shell:${sessionId}`,
{ sessionId, cwd: workingDir, command },
taskId,
);

let resolveExit: (result: { exitCode: number }) => void;
const exitPromise = new Promise<{ exitCode: number }>((resolve) => {
resolveExit = resolve;
});

const disposables: pty.IDisposable[] = [];

disposables.push(
ptyProcess.onData((data: string) => {
this.emit(ShellEvent.Data, { sessionId, data });
}),
);

disposables.push(
ptyProcess.onExit(({ exitCode }) => {
log.info(`Command session ${sessionId} exited with code ${exitCode}`);
this.processTracking.unregister(ptyProcess.pid, "exited");
const session = this.sessions.get(sessionId);
if (session) {
for (const d of session.disposables) {
d.dispose();
}
session.pty.destroy();
this.sessions.delete(sessionId);
}
this.emit(ShellEvent.Exit, { sessionId, exitCode });
resolveExit({ exitCode });
}),
);

const session: ShellSession = {
pty: ptyProcess,
exitPromise,
command,
disposables,
};

this.sessions.set(sessionId, session);
}

write(sessionId: string, data: string): void {
this.getSessionOrThrow(sessionId).pty.write(data);
}
Expand Down
12 changes: 12 additions & 0 deletions apps/code/src/main/trpc/routers/shell.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { container } from "../../di/container";
import { MAIN_TOKENS } from "../../di/tokens";
import {
createCommandInput,
createInput,
executeInput,
executeOutput,
Expand Down Expand Up @@ -38,6 +39,17 @@ export const shellRouter = router({
getService().create(input.sessionId, input.cwd, input.taskId),
),

createCommand: publicProcedure
.input(createCommandInput)
.mutation(({ input }) =>
getService().createCommandSession({
sessionId: input.sessionId,
command: input.command,
cwd: input.cwd,
taskId: input.taskId,
}),
),

write: publicProcedure
.input(writeInput)
.mutation(({ input }) => getService().write(input.sessionId, input.data)),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { Tooltip } from "@components/ui/Tooltip";
import {
getActionSessionId,
useActionStore,
} from "@features/actions/stores/actionStore";
import { terminalManager } from "@features/terminal/services/TerminalManager";
import { ArrowClockwise, Check, X } from "@phosphor-icons/react";
import { Spinner } from "@radix-ui/themes";
import { trpcClient } from "@renderer/trpc/client";
import { useCallback, useState } from "react";

interface ActionTabIconProps {
actionId: string;
}

export function ActionTabIcon({ actionId }: ActionTabIconProps) {
const [hovered, setHovered] = useState(false);
const status = useActionStore((state) => state.statuses[actionId]);
const generation = useActionStore(
(state) => state.generations[actionId] ?? 0,
);
const rerun = useActionStore((state) => state.rerun);

const triggerRerun = useCallback(() => {
const sessionId = getActionSessionId(actionId, generation);
terminalManager.destroy(sessionId);
trpcClient.shell.destroy.mutate({ sessionId });
rerun(actionId);
}, [actionId, generation, rerun]);

const handleClick = useCallback(
(e: React.MouseEvent) => {
if (!hovered) return;
e.stopPropagation();
triggerRerun();
},
[hovered, triggerRerun],
);

let icon: React.ReactNode;
if (hovered) {
icon = <ArrowClockwise size={14} weight="bold" />;
} else if (status === "success") {
icon = <Check size={14} weight="bold" className="text-green-9" />;
} else if (status === "error") {
icon = <X size={14} weight="bold" className="text-red-9" />;
} else {
icon = <Spinner size="1" />;
}

const content = (
<button
type="button"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onClick={handleClick}
style={{
display: "flex",
alignItems: "center",
cursor: hovered ? "pointer" : undefined,
background: "none",
border: "none",
padding: 0,
margin: 0,
color: "inherit",
}}
>
{icon}
</button>
);

if (hovered) {
return (
<Tooltip content="Rerun action" side="bottom">
{content}
</Tooltip>
);
}

return content;
}
64 changes: 64 additions & 0 deletions apps/code/src/renderer/features/actions/stores/actionStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";

export type ActionStatus = "running" | "success" | "error";

export function getActionSessionId(
actionId: string,
generation: number,
): string {
return `action-${actionId}-${generation}`;
}

interface ActionStoreState {
statuses: Record<string, ActionStatus>;
generations: Record<string, number>;
}

interface ActionStoreActions {
setStatus: (actionId: string, status: ActionStatus) => void;
rerun: (actionId: string) => void;
clear: (actionId: string) => void;
}

type ActionStore = ActionStoreState & ActionStoreActions;

export const useActionStore = create<ActionStore>()(
persist(
(set) => ({
statuses: {},
generations: {},

setStatus: (actionId, status) =>
set((state) => ({
statuses: { ...state.statuses, [actionId]: status },
})),

rerun: (actionId) =>
set((state) => {
const { [actionId]: _, ...restStatuses } = state.statuses;
return {
statuses: restStatuses,
generations: {
...state.generations,
[actionId]: (state.generations[actionId] ?? 0) + 1,
},
};
}),

clear: (actionId) =>
set((state) => {
const { [actionId]: _s, ...restStatuses } = state.statuses;
const { [actionId]: _g, ...restGenerations } = state.generations;
return { statuses: restStatuses, generations: restGenerations };
}),
}),
{
name: "action-storage",
partialize: (state) => ({
statuses: state.statuses,
generations: state.generations,
}),
},
),
);
15 changes: 13 additions & 2 deletions apps/code/src/renderer/features/panels/components/TabbedPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,19 @@ import type { PanelContent } from "../store/panelStore";
import { PanelDropZones } from "./PanelDropZones";
import { PanelTab } from "./PanelTab";

const activeTabStyle = { height: "100%" } as const;
const hiddenTabStyle = { display: "none" } as const;
const activeTabStyle: React.CSSProperties = {
height: "100%",
width: "100%",
};
const hiddenTabStyle: React.CSSProperties = {
height: "100%",
width: "100%",
position: "absolute",
top: 0,
left: 0,
visibility: "hidden",
pointerEvents: "none",
};

interface TabBarButtonProps {
ariaLabel: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { FileIcon } from "@components/ui/FileIcon";
import { ActionTabIcon } from "@features/actions/components/ActionTabIcon";
import { useCwd } from "@features/sidebar/hooks/useCwd";
import { TabContentRenderer } from "@features/task-detail/components/TabContentRenderer";
import { ChatCenteredText, Terminal } from "@phosphor-icons/react";
Expand Down Expand Up @@ -102,6 +103,8 @@ export function useTabInjection(
icon = <Terminal size={14} />;
} else if (tab.data.type === "logs") {
icon = <ChatCenteredText size={14} />;
} else if (tab.data.type === "action") {
icon = <ActionTabIcon actionId={tab.data.actionId} />;
}
}

Expand Down
Loading
Loading