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
22 changes: 0 additions & 22 deletions apps/code/src/renderer/api/posthogClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2721,28 +2721,6 @@ export class PostHogAPIClient {
}
}

/** Find an exported asset by session recording ID. */
async findExportBySessionRecordingId(
projectId: number,
sessionRecordingId: string,
): Promise<number | null> {
const urlPath = `/api/projects/${projectId}/exports/`;
const url = new URL(`${this.api.baseUrl}${urlPath}`);
url.searchParams.set("session_recording_id", sessionRecordingId);
url.searchParams.set("export_format", "video/mp4");
const response = await this.api.fetcher.fetch({
method: "get",
url,
path: urlPath,
});
if (!response.ok) return null;
const data = (await response.json()) as {
results?: Array<{ id: number; has_content: boolean }>;
};
const match = data.results?.find((e) => e.has_content);
return match?.id ?? null;
}

/** Get the presigned content URL for an exported asset (e.g. rasterized recording). */
async getExportContentUrl(
projectId: number,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
} from "@phosphor-icons/react";
import { Button, Spinner, Text, Tooltip } from "@radix-ui/themes";
import type { SignalReportStatus, SignalReportTask, Task } from "@shared/types";
import { useState } from "react";
import { Fragment, useState } from "react";

type Relationship = SignalReportTask["relationship"];

Expand Down Expand Up @@ -291,6 +291,7 @@ export function ReportTaskLogs({
const expandedBar = expanded
? (bars.find((b) => b.relationship === expanded && b.task) ?? null)
: null;

const totalBarsHeight = BAR_HEIGHT * bars.length;

return (
Expand Down Expand Up @@ -323,10 +324,11 @@ export function ReportTaskLogs({
top: expandedBar ? "15%" : `calc(100% - ${totalBarsHeight}px)`,
transition: "top 0.25s cubic-bezier(0.32, 0.72, 0, 1)",
}}
className="pointer-events-none absolute right-0 bottom-0 left-0 flex flex-col border-t border-t-(--gray-6) bg-(--color-background)"
className="pointer-events-none absolute right-0 bottom-0 left-0 flex min-h-0 flex-col border-t border-t-(--gray-6) bg-(--color-background)"
>
{/* Stacked header bars — one per task relationship. */}
<div className="pointer-events-auto shrink-0">
{/* Pipeline order: fixed bar positions; each task type gets its TaskLogsPanel
directly beneath its own header (not a shared pane under both bars). */}
<div className="pointer-events-auto flex min-h-0 flex-1 flex-col overflow-hidden">
{bars.map((bar, index) => {
const {
relationship,
Expand All @@ -346,7 +348,7 @@ export function ReportTaskLogs({
const hideStatusLabel = showRunAction && !task;

const rowClassName = [
"flex w-full items-center gap-2 bg-transparent px-2 @md:px-3 @lg:px-4 @xl:px-5 @2xl:px-6 @3xl:px-8 @4xl:px-10 @5xl:px-12 py-2 text-left transition-colors",
"flex w-full shrink-0 items-center gap-2 bg-transparent px-2 @md:px-3 @lg:px-4 @xl:px-5 @2xl:px-6 @3xl:px-8 @4xl:px-10 @5xl:px-12 py-2 text-left transition-colors",
index > 0 ? "border-gray-5 border-t" : "",
isInteractive
? "cursor-pointer hover:bg-gray-2"
Expand Down Expand Up @@ -436,7 +438,6 @@ export function ReportTaskLogs({
showRunAction ? (
// biome-ignore lint/a11y/useSemanticElements: a <button> can't contain the nested run-action <button>
<div
key={relationship}
role="button"
tabIndex={0}
onClick={toggleExpand}
Expand All @@ -454,7 +455,6 @@ export function ReportTaskLogs({
</div>
) : (
<button
key={relationship}
type="button"
onClick={toggleExpand}
className={rowClassName}
Expand All @@ -464,40 +464,36 @@ export function ReportTaskLogs({
</button>
)
) : (
<div
key={relationship}
className={rowClassName}
style={{ height: BAR_HEIGHT }}
>
<div className={rowClassName} style={{ height: BAR_HEIGHT }}>
{rowInner}
</div>
);

return tooltip ? (
<Tooltip key={relationship} content={tooltip}>
{row}
</Tooltip>
const rowWrapped = tooltip ? (
<Tooltip content={tooltip}>{row}</Tooltip>
) : (
row
);
})}
</div>

{/* Expanded logs body — only rendered for the selected task. */}
<div
style={{
pointerEvents: expandedBar ? "auto" : "none",
}}
className="min-h-0 flex-1 overflow-hidden"
>
{expandedBar?.task && (
<TaskLogsPanel
key={expandedBar.task.id}
taskId={expandedBar.task.id}
task={expandedBar.task}
hideInput={reportStatus !== "ready"}
/>
)}
const logPanel =
isExpanded && task ? (
<div className="min-h-0 flex-1 overflow-hidden">
<TaskLogsPanel
key={`report-logs:${relationship}:${task.id}`}
taskId={task.id}
task={task}
hideInput={reportStatus !== "ready"}
/>
</div>
) : null;

return (
<Fragment key={relationship}>
{rowWrapped}
{logPanel}
</Fragment>
);
})}
</div>
</div>
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ import {
QuestionIcon,
TagIcon,
} from "@phosphor-icons/react";
import { Badge, Box, Flex, Text } from "@radix-ui/themes";
import { Badge, Box, Button, Flex, Text } from "@radix-ui/themes";
import type { Signal, SignalFindingContent } from "@shared/types";
import { useQueryClient } from "@tanstack/react-query";
import { useRef, useState } from "react";

const COLLAPSE_THRESHOLD = 300;
Expand Down Expand Up @@ -542,11 +543,8 @@ function SessionProblemSignalCard({
)}
<CollapsibleBody body={signal.content} />

{extra.session_id && (
<SessionRecordingVideo
exportedAssetId={extra.exported_asset_id}
sessionId={extra.session_id}
/>
{extra.exported_asset_id != null && (
<SessionRecordingVideo exportedAssetId={extra.exported_asset_id} />
)}

<Flex
Expand Down Expand Up @@ -607,33 +605,62 @@ function SessionProblemSignalCard({

function SessionRecordingVideo({
exportedAssetId,
sessionId,
}: {
exportedAssetId?: number;
sessionId: string;
exportedAssetId: number;
}) {
const projectId = useAuthStateValue((state) => state.projectId);
const queryClient = useQueryClient();
const videoRef = useRef<HTMLVideoElement>(null);
const videoQuery = useAuthenticatedQuery<string | null>(
["export-video", projectId, exportedAssetId, sessionId],
["export-video", projectId, exportedAssetId],
async (client) => {
if (!projectId) return null;
let assetId: number | null = exportedAssetId ?? null;
// If no asset ID in the signal, look up the export by session_id
if (assetId == null) {
assetId = await client.findExportBySessionRecordingId(
projectId,
sessionId,
);
if (assetId == null) return null;
}
return client.getExportContentUrl(projectId, assetId);
return client.getExportContentUrl(projectId, exportedAssetId);
},
{ enabled: !!projectId, staleTime: Infinity },
);

if (videoQuery.isError || videoQuery.data === null) return null;
if (videoQuery.isLoading || videoQuery.data === undefined) {
const retryExportVideo = () => {
void queryClient.invalidateQueries({
queryKey: ["export-video", projectId, exportedAssetId],
});
};

if (videoQuery.isError) {
return (
<Flex
mt="2"
direction="column"
gap="2"
className="rounded border border-gray-6 bg-gray-2 p-3 text-[11px] text-gray-11"
>
<Text>Could not load recording.</Text>
<Box>
<Button
size="1"
variant="soft"
color="gray"
onClick={retryExportVideo}
>
Retry
</Button>
</Box>
</Flex>
);
}

if (videoQuery.data === null) {
return (
<Box
mt="2"
className="flex h-16 items-center justify-center rounded bg-gray-3 text-[11px] text-gray-9"
>
No playable export for this session yet.
</Box>
);
}

if (videoQuery.isPending || videoQuery.data === undefined) {
return (
<Box
mt="2"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { useCwd } from "@features/sidebar/hooks/useCwd";
import { useIsCloudTask } from "@features/workspace/hooks/useIsCloudTask";
import { useWorkspace } from "@features/workspace/hooks/useWorkspace";
import type { Task } from "@shared/types";
import { useSessionForTask } from "../stores/sessionStore";
Expand All @@ -8,7 +7,10 @@ export function useSessionViewState(taskId: string, task: Task) {
const session = useSessionForTask(taskId);
const repoPath = useCwd(taskId) ?? null;
const workspace = useWorkspace(taskId);
const isCloud = useIsCloudTask(taskId);
const isCloud =
workspace != null
? workspace.mode === "cloud"
: task.latest_run?.environment === "cloud";

const cloudStatus = session?.cloudStatus ?? null;
const isCloudRunNotTerminal =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,20 @@ import { WorkspaceSetupPrompt } from "@features/task-detail/components/Workspace
import { useBranchMismatchDialog } from "@features/workspace/hooks/useBranchMismatchDialog";
import {
useCreateWorkspace,
useWorkspace,
useWorkspaceLoaded,
workspaceApi,
} from "@features/workspace/hooks/useWorkspace";
import { Box, Flex } from "@radix-ui/themes";
import { useTRPC } from "@renderer/trpc/client";
import type { Task } from "@shared/types";
import { useQueryClient } from "@tanstack/react-query";
import { logger } from "@utils/logger";
import { getTaskRepository } from "@utils/repository";
import { useCallback, useEffect } from "react";

const bootstrapCloudLog = logger.scope("task-logs-panel-bootstrap");

interface TaskLogsPanelProps {
taskId: string;
task: Task;
Expand All @@ -30,7 +37,10 @@ interface TaskLogsPanelProps {
}

export function TaskLogsPanel({ taskId, task, hideInput }: TaskLogsPanelProps) {
const trpcReact = useTRPC();
const queryClient = useQueryClient();
const isWorkspaceLoaded = useWorkspaceLoaded();
const persistedWorkspace = useWorkspace(taskId);
const { isPending: isCreatingWorkspace } = useCreateWorkspace();
const repoKey = getTaskRepository(task);
const { folders } = useFolders();
Expand Down Expand Up @@ -94,6 +104,50 @@ export function TaskLogsPanel({ taskId, task, hideInput }: TaskLogsPanelProps) {
requestFocus(taskId);
}, [taskId, requestFocus]);

useEffect(() => {
if (!isWorkspaceLoaded) return;
if (persistedWorkspace) return;
if (task.latest_run?.environment !== "cloud") return;
if (isSuspended) return;
if (isProvisioning) return;

Comment on lines +107 to +113
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 No in-flight guard for concurrent bootstrap calls

The effect fires whenever any of its dependencies change (e.g., isSuspended toggling). If a dependency change triggers the cleanup and immediately re-fires the effect while the previous workspaceApi.create call is still awaiting a response, two creates are in flight simultaneously — persistedWorkspace is still null for both, so neither guard catches the other.

Bypassing the useMutation path also means isCreatingWorkspace doesn't reflect the bootstrap's pending state. Consider a useRef in-flight flag or using the existing mutation hook to serialise concurrent bootstrap attempts.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/code/src/renderer/features/task-detail/components/TaskLogsPanel.tsx
Line: 107-113

Comment:
**No in-flight guard for concurrent bootstrap calls**

The effect fires whenever any of its dependencies change (e.g., `isSuspended` toggling). If a dependency change triggers the cleanup and immediately re-fires the effect while the previous `workspaceApi.create` call is still awaiting a response, two creates are in flight simultaneously — `persistedWorkspace` is still `null` for both, so neither guard catches the other.

Bypassing the `useMutation` path also means `isCreatingWorkspace` doesn't reflect the bootstrap's pending state. Consider a `useRef` in-flight flag or using the existing mutation hook to serialise concurrent bootstrap attempts.

How can I resolve this? If you propose a fix, please make it concise.

let cancelled = false;
void (async () => {
try {
await workspaceApi.create({
taskId,
mainRepoPath: "",
folderId: "",
folderPath: "",
mode: "cloud",
});
} catch (error) {
bootstrapCloudLog.warn("Cloud workspace bootstrap failed", {
taskId,
error,
});
}
if (!cancelled) {
void queryClient.invalidateQueries(
trpcReact.workspace.getAll.pathFilter(),
);
}
Comment on lines +130 to +134
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Cache invalidation skipped on early unmount

If the component unmounts while workspaceApi.create is still in flight, the cleanup sets cancelled = true, the creation still succeeds on the server, but queryClient.invalidateQueries is never called. On the next mount, persistedWorkspace is still null (the query's staleTime is 60 s so it won't re-fetch), the effect fires again, and a second create is attempted.

invalidateQueries updates the shared cache — it is safe to call even after the component that triggered it has unmounted. The guard should be removed:

      } catch (error) {
        bootstrapCloudLog.warn("Cloud workspace bootstrap failed", { taskId, error });
      }
      // Always invalidate — cache updates are safe after unmount and prevent
      // a stale null workspace from causing a duplicate-create on next mount.
      void queryClient.invalidateQueries(
        trpcReact.workspace.getAll.pathFilter(),
      );
    })();
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/code/src/renderer/features/task-detail/components/TaskLogsPanel.tsx
Line: 130-134

Comment:
**Cache invalidation skipped on early unmount**

If the component unmounts while `workspaceApi.create` is still in flight, the cleanup sets `cancelled = true`, the creation still succeeds on the server, but `queryClient.invalidateQueries` is never called. On the next mount, `persistedWorkspace` is still `null` (the query's `staleTime` is 60 s so it won't re-fetch), the effect fires again, and a second create is attempted.

`invalidateQueries` updates the shared cache — it is safe to call even after the component that triggered it has unmounted. The guard should be removed:

```ts
      } catch (error) {
        bootstrapCloudLog.warn("Cloud workspace bootstrap failed", { taskId, error });
      }
      // Always invalidate — cache updates are safe after unmount and prevent
      // a stale null workspace from causing a duplicate-create on next mount.
      void queryClient.invalidateQueries(
        trpcReact.workspace.getAll.pathFilter(),
      );
    })();
```

How can I resolve this? If you propose a fix, please make it concise.

})();

return () => {
cancelled = true;
};
}, [
isProvisioning,
isSuspended,
isWorkspaceLoaded,
persistedWorkspace,
queryClient,
task.latest_run?.environment,
taskId,
trpcReact,
]);

const handleRestoreWorktree = useCallback(async () => {
await restoreTask(taskId);
}, [taskId, restoreTask]);
Expand Down
Loading