Skip to content
1 change: 1 addition & 0 deletions apps/twig/src/main/services/agent/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1016,6 +1016,7 @@ For git operations while detached:
}
this.cleanupMockNodeEnvironment(session.mockNodeDir);
this.sessions.delete(taskRunId);
this.processTracking.killByTaskId(session.taskId);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -381,13 +381,12 @@ export function SessionView({
className="absolute inset-0 bg-gray-1"
>
<Warning size={32} weight="duotone" color="var(--red-9)" />
<Text size="3" weight="medium" color="red">
Session Error
</Text>
<Text
size="2"
size="3"
weight="medium"
align="center"
className="max-w-md px-4 text-gray-11"
color="red"
className="max-w-md px-4"
>
{errorMessage}
</Text>
Expand Down
27 changes: 26 additions & 1 deletion apps/twig/src/renderer/features/sessions/service/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ vi.mock("@features/sessions/stores/modelsStore", () => ({
const mockSessionConfigStore = vi.hoisted(() => ({
getPersistedConfigOptions: vi.fn(() => undefined),
setPersistedConfigOptions: vi.fn(),
removePersistedConfigOptions: vi.fn(),
updatePersistedConfigOptionValue: vi.fn(),
}));

Expand All @@ -89,6 +90,26 @@ vi.mock(
() => mockSessionConfigStore,
);

const mockAdapterFns = vi.hoisted(() => ({
setAdapter: vi.fn(),
getAdapter: vi.fn(),
removeAdapter: vi.fn(),
}));

const mockSessionAdapterStore = vi.hoisted(() => ({
useSessionAdapterStore: {
getState: vi.fn(() => ({
adaptersByRunId: {},
...mockAdapterFns,
})),
},
}));

vi.mock(
"@features/sessions/stores/sessionAdapterStore",
() => mockSessionAdapterStore,
);

const mockGetIsOnline = vi.hoisted(() => vi.fn(() => true));

vi.mock("@/renderer/stores/connectivityStore", () => ({
Expand Down Expand Up @@ -732,7 +753,7 @@ describe("SessionService", () => {
});

describe("clearSessionError", () => {
it("cancels agent and removes session", async () => {
it("cancels agent and tears down session fully", async () => {
const service = getSessionService();
const mockSession = createMockSession({ status: "error" });
mockSessionStoreSetters.getSessionByTaskId.mockReturnValue(mockSession);
Expand All @@ -745,6 +766,10 @@ describe("SessionService", () => {
expect(mockSessionStoreSetters.removeSession).toHaveBeenCalledWith(
"run-123",
);
expect(mockAdapterFns.removeAdapter).toHaveBeenCalledWith("run-123");
expect(
mockSessionConfigStore.removePersistedConfigOptions,
).toHaveBeenCalledWith("run-123");
});

it("handles missing session gracefully", async () => {
Expand Down
127 changes: 87 additions & 40 deletions apps/twig/src/renderer/features/sessions/service/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { useModelsStore } from "@features/sessions/stores/modelsStore";
import { useSessionAdapterStore } from "@features/sessions/stores/sessionAdapterStore";
import {
getPersistedConfigOptions,
removePersistedConfigOptions,
setPersistedConfigOptions,
updatePersistedConfigOptionValue,
} from "@features/sessions/stores/sessionConfigStore";
Expand Down Expand Up @@ -361,26 +362,98 @@ export class SessionService {
}
}
} else {
this.unsubscribeFromChannel(taskRunId);
sessionStoreSetters.updateSession(taskRunId, {
status: "error",
errorMessage:
"Failed to reconnect to the agent. Please restart the task.",
log.warn("Reconnect returned null, falling back to new session", {
taskId,
taskRunId,
});
await this.recreateOrError(
taskRunId,
taskId,
taskTitle,
repoPath,
auth,
"Failed to start a new session. Please try again.",
);
}
} catch (error) {
this.unsubscribeFromChannel(taskRunId);
const errorMessage =
error instanceof Error ? error.message : String(error);
log.error("Failed to reconnect to session", { taskId, error });
sessionStoreSetters.updateSession(taskRunId, {
status: "error",
errorMessage:
errorMessage || "Failed to reconnect to the agent. Please try again.",
log.warn("Reconnect failed, falling back to new session", {
taskId,
error: errorMessage,
});
await this.recreateOrError(
taskRunId,
taskId,
taskTitle,
repoPath,
auth,
errorMessage || "Failed to reconnect. Please try again.",
);
}
}

private async teardownSession(taskRunId: string): Promise<void> {
try {
await trpcVanilla.agent.cancel.mutate({ sessionId: taskRunId });
} catch (error) {
log.debug("Cancel during teardown failed (session may already be gone)", {
taskRunId,
error: error instanceof Error ? error.message : String(error),
});
}

this.unsubscribeFromChannel(taskRunId);
sessionStoreSetters.removeSession(taskRunId);
useSessionAdapterStore.getState().removeAdapter(taskRunId);
removePersistedConfigOptions(taskRunId);
}

private async recreateOrError(
taskRunId: string,
taskId: string,
taskTitle: string,
repoPath: string,
auth: AuthCredentials,
fallbackMessage: string,
): Promise<void> {
try {
await this.recreateSession(taskRunId, taskId, taskTitle, repoPath, auth);
} catch (recreateError) {
log.error("Failed to recreate session", {
taskId,
error:
recreateError instanceof Error
? recreateError.message
: String(recreateError),
});
this.setErrorSession(taskId, taskRunId, taskTitle, fallbackMessage);
}
}

private setErrorSession(
taskId: string,
taskRunId: string,
taskTitle: string,
errorMessage: string,
): void {
const session = this.createBaseSession(taskRunId, taskId, taskTitle);
session.status = "error";
session.errorMessage = errorMessage;
sessionStoreSetters.setSession(session);
}

private async recreateSession(
oldTaskRunId: string,
taskId: string,
taskTitle: string,
repoPath: string,
auth: AuthCredentials,
): Promise<void> {
await this.teardownSession(oldTaskRunId);
await this.createNewLocalSession(taskId, taskTitle, repoPath, auth);
}

private async createNewLocalSession(
taskId: string,
taskTitle: string,
Expand Down Expand Up @@ -468,18 +541,7 @@ export class SessionService {
const session = sessionStoreSetters.getSessionByTaskId(taskId);
if (!session) return;

try {
await trpcVanilla.agent.cancel.mutate({
sessionId: session.taskRunId,
});
} catch (error) {
log.error("Failed to cancel agent session", {
taskRunId: session.taskRunId,
error,
});
}
this.unsubscribeFromChannel(session.taskRunId);
sessionStoreSetters.removeSession(session.taskRunId);
await this.teardownSession(session.taskRunId);
}

// --- Preview Session Management ---
Expand Down Expand Up @@ -1233,27 +1295,12 @@ export class SessionService {

/**
* Clear session error and allow retry.
* Tears down the old session; events are re-fetched from logs during reconnect.
*/
async clearSessionError(taskId: string): Promise<void> {
const session = sessionStoreSetters.getSessionByTaskId(taskId);
if (session) {
// Cancel the agent session on the main process
try {
await trpcVanilla.agent.cancel.mutate({
sessionId: session.taskRunId,
});
log.info("Cancelled agent session for retry", {
taskId,
taskRunId: session.taskRunId,
});
} catch (error) {
log.warn("Failed to cancel agent session during error clear", {
taskId,
error,
});
}
this.unsubscribeFromChannel(session.taskRunId);
sessionStoreSetters.removeSession(session.taskRunId);
await this.teardownSession(session.taskRunId);
}
// Clear from connecting tasks as well
this.connectingTasks.delete(taskId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ export function setPersistedConfigOptions(
useSessionConfigStore.getState().setConfigOptions(taskRunId, options);
}

/** Non-hook accessor for removing persisted config options */
export function removePersistedConfigOptions(taskRunId: string): void {
useSessionConfigStore.getState().removeConfigOptions(taskRunId);
}

/** Non-hook accessor for updating a single config option value */
export function updatePersistedConfigOptionValue(
taskRunId: string,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
} from "@phosphor-icons/react";
import type { WorkspaceMode } from "@shared/types";
import { selectIsFocusedOnWorktree, useFocusStore } from "@stores/focusStore";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { SidebarItem } from "../SidebarItem";

interface TaskItemProps {
Expand Down Expand Up @@ -238,31 +238,28 @@ export function TaskItem({
</Tooltip>
);

const endContent = useMemo(() => {
const timestampNode = timestamp ? (
<span className="shrink-0 text-[10px] text-gray-11 group-hover:hidden">
{formatRelativeTime(timestamp)}
</span>
) : null;

const toolbar =
onDelete || onTogglePin ? (
<TaskHoverToolbar
isPinned={isPinned}
onTogglePin={onTogglePin}
onDelete={onDelete}
/>
) : null;
const timestampNode = timestamp ? (
<span className="shrink-0 text-[10px] text-gray-11 group-hover:hidden">
{formatRelativeTime(timestamp)}
</span>
) : null;

if (!timestampNode && !toolbar) return null;
const toolbar =
onDelete || onTogglePin ? (
<TaskHoverToolbar
isPinned={isPinned}
onTogglePin={onTogglePin}
onDelete={onDelete}
/>
) : null;

return (
const endContent =
timestampNode || toolbar ? (
<>
{timestampNode}
{toolbar}
</>
);
}, [timestamp, onDelete, onTogglePin, isPinned]);
) : null;

if (isEditing) {
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,19 @@ import {
} from "@features/sessions/stores/sessionStore";
import { useCwd } from "@features/sidebar/hooks/useCwd";
import { useTaskViewedStore } from "@features/sidebar/stores/taskViewedStore";
import { WorkspaceSetupPrompt } from "@features/task-detail/components/WorkspaceSetupPrompt";
import { useDeleteTask } from "@features/tasks/hooks/useTasks";
import { useWorkspaceStore } from "@features/workspace/stores/workspaceStore";
import { useConnectivity } from "@hooks/useConnectivity";
import { Box, Button, Flex, Spinner, Text } from "@radix-ui/themes";
import { track } from "@renderer/lib/analytics";
import { logger } from "@renderer/lib/logger";
import { useNavigationStore } from "@renderer/stores/navigationStore";
import { useTaskDirectoryStore } from "@renderer/stores/taskDirectoryStore";
import { trpcVanilla } from "@renderer/trpc/client";
import type { Task } from "@shared/types";
import { useQueryClient } from "@tanstack/react-query";
import { getTaskRepository } from "@utils/repository";
import { toast } from "@utils/toast";
import { useCallback, useEffect, useRef } from "react";
import { ANALYTICS_EVENTS, type FeedbackType } from "@/types/analytics";
Expand All @@ -34,6 +37,12 @@ export function TaskLogsPanel({ taskId, task }: TaskLogsPanelProps) {
const repoPath = useCwd(taskId);
const workspace = useWorkspaceStore((s) => s.workspaces[taskId]);
const queryClient = useQueryClient();
const isWorkspaceLoaded = useWorkspaceStore((s) => s.isLoaded);
const isCreatingWorkspace = useWorkspaceStore((s) => !!s.isCreating[taskId]);
const repoKey = getTaskRepository(task);
const hasDirectoryMapping = useTaskDirectoryStore(
(s) => !!repoKey && repoKey in s.repoDirectories,
);

const session = useSessionForTask(taskId);
const { deleteWithConfirm } = useDeleteTask();
Expand Down Expand Up @@ -72,7 +81,7 @@ export function TaskLogsPanel({ taskId, task }: TaskLogsPanelProps) {
const isInitializing = isCloud
? !session || (events.length === 0 && isCloudRunNotTerminal)
: !session ||
session.status === "connecting" ||
(session.status === "connecting" && events.length === 0) ||
(session.status === "connected" &&
events.length === 0 &&
(isPromptPending ||
Expand Down Expand Up @@ -284,6 +293,21 @@ export function TaskLogsPanel({ taskId, task }: TaskLogsPanelProps) {
[taskId, repoPath],
);

if (
!repoPath &&
isWorkspaceLoaded &&
!hasDirectoryMapping &&
!isCreatingWorkspace
) {
return (
<BackgroundWrapper>
<Box height="100%" width="100%">
<WorkspaceSetupPrompt taskId={taskId} task={task} />
</Box>
</BackgroundWrapper>
);
}

return (
<BackgroundWrapper>
<Flex direction="column" height="100%" width="100%">
Expand Down
Loading
Loading