sandbox-agent/foundry/packages/frontend/src/components/mock-layout.tsx
2026-03-15 20:53:54 -07:00

2282 lines
82 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { useNavigate } from "@tanstack/react-router";
import { useStyletron } from "baseui";
import {
createErrorContext,
type FoundryOrganization,
type TaskWorkbenchSnapshot,
type WorkbenchOpenPrSummary,
type WorkbenchSessionSummary,
type WorkbenchTaskDetail,
type WorkbenchTaskSummary,
} from "@sandbox-agent/foundry-shared";
import { useSubscription } from "@sandbox-agent/foundry-client";
import { CircleAlert, PanelLeft, PanelRight } from "lucide-react";
import { useFoundryTokens } from "../app/theme";
import { logger } from "../logging.js";
import { DiffContent } from "./mock-layout/diff-content";
import { MessageList } from "./mock-layout/message-list";
import { PromptComposer } from "./mock-layout/prompt-composer";
import { RightSidebar } from "./mock-layout/right-sidebar";
import { Sidebar } from "./mock-layout/sidebar";
import { SessionStrip } from "./mock-layout/session-strip";
import { TerminalPane } from "./mock-layout/terminal-pane";
import { TranscriptHeader } from "./mock-layout/transcript-header";
import { PROMPT_TEXTAREA_MAX_HEIGHT, PROMPT_TEXTAREA_MIN_HEIGHT, SPanel, ScrollBody, Shell, SpinnerDot } from "./mock-layout/ui";
import { DevPanel, useDevPanel } from "./dev-panel";
import {
buildDisplayMessages,
diffPath,
diffTabId,
formatThinkingDuration,
isDiffTab,
buildHistoryEvents,
type Task,
type HistoryEvent,
type LineAttachment,
type Message,
type ModelId,
} from "./mock-layout/view-model";
import { activeMockOrganization, useMockAppSnapshot } from "../lib/mock-app";
import { backendClient } from "../lib/backend";
import { subscriptionManager } from "../lib/subscription";
import { describeTaskState, isProvisioningTaskStatus } from "../features/tasks/status";
function firstAgentSessionId(task: Task): string | null {
return task.sessions[0]?.id ?? null;
}
function sanitizeOpenDiffs(task: Task, paths: string[] | undefined): string[] {
if (!paths) {
return [];
}
return paths.filter((path) => task.diffs[path] != null);
}
function sanitizeLastAgentSessionId(task: Task, sessionId: string | null | undefined): string | null {
if (sessionId && task.sessions.some((tab) => tab.id === sessionId)) {
return sessionId;
}
return firstAgentSessionId(task);
}
function sanitizeActiveSessionId(task: Task, sessionId: string | null | undefined, openDiffs: string[], lastAgentSessionId: string | null): string | null {
if (sessionId) {
if (task.sessions.some((tab) => tab.id === sessionId)) {
return sessionId;
}
if (isDiffTab(sessionId) && openDiffs.includes(diffPath(sessionId))) {
return sessionId;
}
}
return openDiffs.length > 0 ? diffTabId(openDiffs[openDiffs.length - 1]!) : lastAgentSessionId;
}
function githubInstallationWarningTitle(organization: FoundryOrganization): string {
return organization.github.installationStatus === "install_required" ? "GitHub App not installed" : "GitHub App needs reconnection";
}
function githubInstallationWarningDetail(organization: FoundryOrganization): string {
const statusDetail = organization.github.lastSyncLabel.trim();
const requirementDetail =
organization.github.installationStatus === "install_required"
? "Webhooks are required for Foundry to function. Repo sync and PR updates will not work until the GitHub App is installed for this organization."
: "Webhook delivery is unavailable. Repo sync and PR updates will not work until the GitHub App is reconnected.";
return statusDetail ? `${requirementDetail} ${statusDetail}.` : requirementDetail;
}
function GithubInstallationWarning({
organization,
css,
t,
}: {
organization: FoundryOrganization;
css: ReturnType<typeof useStyletron>[0];
t: ReturnType<typeof useFoundryTokens>;
}) {
if (organization.github.installationStatus === "connected") {
return null;
}
return (
<div
className={css({
position: "fixed",
bottom: "8px",
left: "8px",
zIndex: 99998,
display: "flex",
alignItems: "flex-start",
gap: "8px",
padding: "10px 12px",
backgroundColor: t.surfaceElevated,
border: `1px solid ${t.statusError}`,
borderRadius: "6px",
boxShadow: t.shadow,
maxWidth: "440px",
})}
>
<CircleAlert size={15} color={t.statusError} />
<div className={css({ display: "flex", flexDirection: "column", gap: "3px" })}>
<div className={css({ fontSize: "11px", fontWeight: 600, color: t.textPrimary })}>{githubInstallationWarningTitle(organization)}</div>
<div className={css({ fontSize: "11px", lineHeight: 1.45, color: t.textMuted })}>{githubInstallationWarningDetail(organization)}</div>
</div>
</div>
);
}
function toSessionModel(
summary: WorkbenchSessionSummary,
sessionDetail?: { draft: Task["sessions"][number]["draft"]; transcript: Task["sessions"][number]["transcript"] },
): Task["sessions"][number] {
return {
id: summary.id,
sessionId: summary.sessionId,
sessionName: summary.sessionName,
agent: summary.agent,
model: summary.model,
status: summary.status,
thinkingSinceMs: summary.thinkingSinceMs,
unread: summary.unread,
created: summary.created,
errorMessage: summary.errorMessage ?? null,
draft: sessionDetail?.draft ?? {
text: "",
attachments: [],
updatedAtMs: null,
},
transcript: sessionDetail?.transcript ?? [],
};
}
function toTaskModel(
summary: WorkbenchTaskSummary,
detail?: WorkbenchTaskDetail,
sessionCache?: Map<string, { draft: Task["sessions"][number]["draft"]; transcript: Task["sessions"][number]["transcript"] }>,
): Task {
const sessions = detail?.sessionsSummary ?? summary.sessionsSummary;
return {
id: summary.id,
repoId: summary.repoId,
title: detail?.title ?? summary.title,
status: detail?.runtimeStatus ?? detail?.status ?? summary.status,
runtimeStatus: detail?.runtimeStatus,
statusMessage: detail?.statusMessage ?? null,
repoName: detail?.repoName ?? summary.repoName,
updatedAtMs: detail?.updatedAtMs ?? summary.updatedAtMs,
branch: detail?.branch ?? summary.branch,
pullRequest: detail?.pullRequest ?? summary.pullRequest,
sessions: sessions.map((session) => toSessionModel(session, sessionCache?.get(session.id))),
fileChanges: detail?.fileChanges ?? [],
diffs: detail?.diffs ?? {},
fileTree: detail?.fileTree ?? [],
minutesUsed: detail?.minutesUsed ?? 0,
activeSandboxId: detail?.activeSandboxId ?? null,
};
}
const OPEN_PR_TASK_PREFIX = "pr:";
function openPrTaskId(prId: string): string {
return `${OPEN_PR_TASK_PREFIX}${prId}`;
}
function isOpenPrTaskId(taskId: string): boolean {
return taskId.startsWith(OPEN_PR_TASK_PREFIX);
}
function toOpenPrTaskModel(pullRequest: WorkbenchOpenPrSummary): Task {
return {
id: openPrTaskId(pullRequest.prId),
repoId: pullRequest.repoId,
title: pullRequest.title,
status: "new",
runtimeStatus: undefined,
statusMessage: pullRequest.authorLogin ? `@${pullRequest.authorLogin}` : null,
repoName: pullRequest.repoFullName,
updatedAtMs: pullRequest.updatedAtMs,
branch: pullRequest.headRefName,
pullRequest: {
number: pullRequest.number,
status: pullRequest.isDraft ? "draft" : "ready",
},
sessions: [],
fileChanges: [],
diffs: {},
fileTree: [],
minutesUsed: 0,
activeSandboxId: null,
};
}
function sessionStateMessage(tab: Task["sessions"][number] | null | undefined): string | null {
if (!tab) {
return null;
}
if (tab.status === "pending_provision") {
return "Provisioning sandbox...";
}
if (tab.status === "pending_session_create") {
return "Creating session...";
}
if (tab.status === "error") {
return tab.errorMessage ?? "Session failed to start.";
}
return null;
}
function groupRepositories(repos: Array<{ id: string; label: string }>, tasks: Task[]) {
return repos
.map((repo) => ({
id: repo.id,
label: repo.label,
updatedAtMs: tasks.filter((task) => task.repoId === repo.id).reduce((latest, task) => Math.max(latest, task.updatedAtMs), 0),
tasks: tasks.filter((task) => task.repoId === repo.id).sort((left, right) => right.updatedAtMs - left.updatedAtMs),
}))
.filter((repo) => repo.tasks.length > 0);
}
interface WorkbenchActions {
createTask(input: {
repoId: string;
task: string;
title?: string;
branch?: string;
onBranch?: string;
model?: ModelId;
}): Promise<{ taskId: string; sessionId?: string }>;
markTaskUnread(input: { taskId: string }): Promise<void>;
renameTask(input: { taskId: string; value: string }): Promise<void>;
renameBranch(input: { taskId: string; value: string }): Promise<void>;
archiveTask(input: { taskId: string }): Promise<void>;
publishPr(input: { taskId: string }): Promise<void>;
revertFile(input: { taskId: string; path: string }): Promise<void>;
updateDraft(input: { taskId: string; sessionId: string; text: string; attachments: LineAttachment[] }): Promise<void>;
sendMessage(input: { taskId: string; sessionId: string; text: string; attachments: LineAttachment[] }): Promise<void>;
stopAgent(input: { taskId: string; sessionId: string }): Promise<void>;
setSessionUnread(input: { taskId: string; sessionId: string; unread: boolean }): Promise<void>;
renameSession(input: { taskId: string; sessionId: string; title: string }): Promise<void>;
closeSession(input: { taskId: string; sessionId: string }): Promise<void>;
addSession(input: { taskId: string; model?: string }): Promise<{ sessionId: string }>;
changeModel(input: { taskId: string; sessionId: string; model: ModelId }): Promise<void>;
reloadGithubOrganization(): Promise<void>;
reloadGithubPullRequests(): Promise<void>;
reloadGithubRepository(repoId: string): Promise<void>;
reloadGithubPullRequest(repoId: string, prNumber: number): Promise<void>;
}
const TranscriptPanel = memo(function TranscriptPanel({
taskWorkbenchClient,
task,
hasSandbox,
activeSessionId,
lastAgentSessionId,
openDiffs,
onSyncRouteSession,
onSetActiveSessionId,
onSetLastAgentSessionId,
onSetOpenDiffs,
sidebarCollapsed,
onToggleSidebar,
onSidebarPeekStart,
onSidebarPeekEnd,
rightSidebarCollapsed,
onToggleRightSidebar,
selectedSessionHydrating = false,
onNavigateToUsage,
}: {
taskWorkbenchClient: WorkbenchActions;
task: Task;
hasSandbox: boolean;
activeSessionId: string | null;
lastAgentSessionId: string | null;
openDiffs: string[];
onSyncRouteSession: (taskId: string, sessionId: string | null, replace?: boolean) => void;
onSetActiveSessionId: (sessionId: string | null) => void;
onSetLastAgentSessionId: (sessionId: string | null) => void;
onSetOpenDiffs: (paths: string[]) => void;
sidebarCollapsed?: boolean;
onToggleSidebar?: () => void;
onSidebarPeekStart?: () => void;
onSidebarPeekEnd?: () => void;
rightSidebarCollapsed?: boolean;
onToggleRightSidebar?: () => void;
selectedSessionHydrating?: boolean;
onNavigateToUsage?: () => void;
}) {
const t = useFoundryTokens();
const [defaultModel, setDefaultModel] = useState<ModelId>("claude-sonnet-4");
const [editingField, setEditingField] = useState<"title" | "branch" | null>(null);
const [editValue, setEditValue] = useState("");
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
const [editingSessionName, setEditingSessionName] = useState("");
const [pendingHistoryTarget, setPendingHistoryTarget] = useState<{ messageId: string; sessionId: string } | null>(null);
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
const [timerNowMs, setTimerNowMs] = useState(() => Date.now());
const [localDraft, setLocalDraft] = useState("");
const [localAttachments, setLocalAttachments] = useState<LineAttachment[]>([]);
const [pendingMessage, setPendingMessage] = useState<{ text: string; sessionId: string; sentAt: number } | null>(null);
const lastEditTimeRef = useRef(0);
const throttleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingDraftRef = useRef<{ text: string; attachments: LineAttachment[] } | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const messageRefs = useRef(new Map<string, HTMLDivElement>());
const activeDiff = activeSessionId && isDiffTab(activeSessionId) ? diffPath(activeSessionId) : null;
const activeAgentSession = activeDiff ? null : (task.sessions.find((candidate) => candidate.id === activeSessionId) ?? task.sessions[0] ?? null);
const promptSession = task.sessions.find((candidate) => candidate.id === lastAgentSessionId) ?? task.sessions[0] ?? null;
const isTerminal = task.status === "archived";
const historyEvents = useMemo(() => buildHistoryEvents(task.sessions), [task.sessions]);
const activeMessages = useMemo(() => buildDisplayMessages(activeAgentSession), [activeAgentSession]);
const taskRuntimeStatus = task.runtimeStatus ?? task.status;
const taskState = describeTaskState(taskRuntimeStatus, task.statusMessage ?? null);
const taskProvisioning = isProvisioningTaskStatus(taskRuntimeStatus);
const taskProvisioningMessage = taskState.detail;
const activeSessionMessage = sessionStateMessage(activeAgentSession);
const showPendingSessionState =
!activeDiff &&
!!activeAgentSession &&
(activeAgentSession.status === "pending_provision" || activeAgentSession.status === "pending_session_create" || activeAgentSession.status === "error") &&
activeMessages.length === 0;
const serverDraft = promptSession?.draft.text ?? "";
const serverAttachments = promptSession?.draft.attachments ?? [];
// Sync server → local only when user hasn't typed recently (3s cooldown)
const DRAFT_SYNC_COOLDOWN_MS = 3_000;
useEffect(() => {
if (Date.now() - lastEditTimeRef.current > DRAFT_SYNC_COOLDOWN_MS) {
setLocalDraft(serverDraft);
setLocalAttachments(serverAttachments);
}
}, [serverDraft, serverAttachments]);
// Reset local draft immediately on session/task switch
useEffect(() => {
lastEditTimeRef.current = 0;
setLocalDraft(promptSession?.draft.text ?? "");
setLocalAttachments(promptSession?.draft.attachments ?? []);
}, [promptSession?.id, task.id]);
// Clear pending message once the real transcript contains a client message newer than when we sent
const pendingMessageClientCount = useRef(0);
useEffect(() => {
if (!pendingMessage) return;
const targetSession = task.sessions.find((s) => s.id === pendingMessage.sessionId);
if (!targetSession) return;
const clientEventCount = targetSession.transcript.filter((event) => event.sender === "client").length;
if (clientEventCount > pendingMessageClientCount.current) {
setPendingMessage(null);
}
}, [task.sessions, pendingMessage]);
const draft = localDraft;
const attachments = localAttachments;
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [activeMessages.length]);
useEffect(() => {
textareaRef.current?.focus();
}, [activeSessionId, task.id]);
useEffect(() => {
setEditingSessionId(null);
setEditingSessionName("");
}, [task.id]);
useLayoutEffect(() => {
const textarea = textareaRef.current;
if (!textarea) {
return;
}
textarea.style.height = `${PROMPT_TEXTAREA_MIN_HEIGHT}px`;
const nextHeight = Math.min(textarea.scrollHeight, PROMPT_TEXTAREA_MAX_HEIGHT);
textarea.style.height = `${Math.max(PROMPT_TEXTAREA_MIN_HEIGHT, nextHeight)}px`;
textarea.style.overflowY = textarea.scrollHeight > PROMPT_TEXTAREA_MAX_HEIGHT ? "auto" : "hidden";
}, [draft, activeSessionId, task.id]);
useEffect(() => {
if (!copiedMessageId) {
return;
}
const timer = setTimeout(() => {
setCopiedMessageId(null);
}, 1_200);
return () => clearTimeout(timer);
}, [copiedMessageId]);
useEffect(() => {
if (!activeAgentSession || activeAgentSession.status !== "running" || activeAgentSession.thinkingSinceMs === null) {
return;
}
setTimerNowMs(Date.now());
const timer = window.setInterval(() => {
setTimerNowMs(Date.now());
}, 1_000);
return () => window.clearInterval(timer);
}, [activeAgentSession?.id, activeAgentSession?.status, activeAgentSession?.thinkingSinceMs]);
useEffect(() => {
if (!activeAgentSession?.unread) {
return;
}
void taskWorkbenchClient.setSessionUnread({
taskId: task.id,
sessionId: activeAgentSession.id,
unread: false,
});
}, [activeAgentSession?.id, activeAgentSession?.unread, task.id]);
const startEditingField = useCallback((field: "title" | "branch", value: string) => {
setEditingField(field);
setEditValue(value);
}, []);
const cancelEditingField = useCallback(() => {
setEditingField(null);
}, []);
const commitEditingField = useCallback(
(field: "title" | "branch") => {
const value = editValue.trim();
if (!value) {
setEditingField(null);
return;
}
if (field === "title") {
void taskWorkbenchClient.renameTask({ taskId: task.id, value });
} else {
void taskWorkbenchClient.renameBranch({ taskId: task.id, value });
}
setEditingField(null);
},
[editValue, task.id],
);
const DRAFT_THROTTLE_MS = 500;
const flushDraft = useCallback(
(text: string, nextAttachments: LineAttachment[], sessionId: string) => {
void taskWorkbenchClient.updateDraft({
taskId: task.id,
sessionId,
text,
attachments: nextAttachments,
});
},
[task.id],
);
// Clean up throttle timer on unmount
useEffect(() => {
return () => {
if (throttleTimerRef.current) {
clearTimeout(throttleTimerRef.current);
}
};
}, []);
const updateDraft = useCallback(
(nextText: string, nextAttachments: LineAttachment[]) => {
if (!promptSession) {
return;
}
// Update local state immediately for responsive typing
lastEditTimeRef.current = Date.now();
setLocalDraft(nextText);
setLocalAttachments(nextAttachments);
// Throttle the network call
pendingDraftRef.current = { text: nextText, attachments: nextAttachments };
if (!throttleTimerRef.current) {
throttleTimerRef.current = setTimeout(() => {
throttleTimerRef.current = null;
if (pendingDraftRef.current) {
flushDraft(pendingDraftRef.current.text, pendingDraftRef.current.attachments, promptSession.id);
pendingDraftRef.current = null;
}
}, DRAFT_THROTTLE_MS);
}
},
[promptSession, flushDraft],
);
const sendMessage = useCallback(() => {
const text = draft.trim();
if (!text || !promptSession) {
return;
}
// Clear draft and show optimistic message immediately (don't wait for server round-trip)
setLocalDraft("");
setLocalAttachments([]);
lastEditTimeRef.current = Date.now();
// Snapshot current client message count so we can detect when the server adds ours
pendingMessageClientCount.current = promptSession.transcript.filter((event) => event.sender === "client").length;
setPendingMessage({ text, sessionId: promptSession.id, sentAt: Date.now() });
onSetActiveSessionId(promptSession.id);
onSetLastAgentSessionId(promptSession.id);
void taskWorkbenchClient.sendMessage({
taskId: task.id,
sessionId: promptSession.id,
text,
attachments,
});
}, [attachments, draft, task.id, onSetActiveSessionId, onSetLastAgentSessionId, promptSession]);
const stopAgent = useCallback(() => {
if (!promptSession) {
return;
}
void taskWorkbenchClient.stopAgent({
taskId: task.id,
sessionId: promptSession.id,
});
}, [task.id, promptSession]);
const switchSession = useCallback(
(sessionId: string) => {
onSetActiveSessionId(sessionId);
if (!isDiffTab(sessionId)) {
onSetLastAgentSessionId(sessionId);
const session = task.sessions.find((candidate) => candidate.id === sessionId);
if (session?.unread) {
void taskWorkbenchClient.setSessionUnread({
taskId: task.id,
sessionId,
unread: false,
});
}
onSyncRouteSession(task.id, sessionId);
}
},
[task.id, task.sessions, onSetActiveSessionId, onSetLastAgentSessionId, onSyncRouteSession],
);
const setSessionUnread = useCallback(
(sessionId: string, unread: boolean) => {
void taskWorkbenchClient.setSessionUnread({ taskId: task.id, sessionId, unread });
},
[task.id],
);
const startRenamingSession = useCallback(
(sessionId: string) => {
const targetSession = task.sessions.find((candidate) => candidate.id === sessionId);
if (!targetSession) {
throw new Error(`Unable to rename missing session ${sessionId}`);
}
setEditingSessionId(sessionId);
setEditingSessionName(targetSession.sessionName);
},
[task.sessions],
);
const cancelSessionRename = useCallback(() => {
setEditingSessionId(null);
setEditingSessionName("");
}, []);
const commitSessionRename = useCallback(() => {
if (!editingSessionId) {
return;
}
const trimmedName = editingSessionName.trim();
if (!trimmedName) {
cancelSessionRename();
return;
}
void taskWorkbenchClient.renameSession({
taskId: task.id,
sessionId: editingSessionId,
title: trimmedName,
});
cancelSessionRename();
}, [cancelSessionRename, editingSessionName, editingSessionId, task.id]);
const closeSession = useCallback(
(sessionId: string) => {
const remainingSessions = task.sessions.filter((candidate) => candidate.id !== sessionId);
const nextSessionId = remainingSessions[0]?.id ?? null;
if (activeSessionId === sessionId) {
onSetActiveSessionId(nextSessionId);
}
if (lastAgentSessionId === sessionId) {
onSetLastAgentSessionId(nextSessionId);
}
onSyncRouteSession(task.id, nextSessionId);
void taskWorkbenchClient.closeSession({ taskId: task.id, sessionId });
},
[activeSessionId, task.id, task.sessions, lastAgentSessionId, onSetActiveSessionId, onSetLastAgentSessionId, onSyncRouteSession],
);
const closeDiffTab = useCallback(
(path: string) => {
const nextOpenDiffs = openDiffs.filter((candidate) => candidate !== path);
onSetOpenDiffs(nextOpenDiffs);
if (activeSessionId === diffTabId(path)) {
onSetActiveSessionId(
nextOpenDiffs.length > 0 ? diffTabId(nextOpenDiffs[nextOpenDiffs.length - 1]!) : (lastAgentSessionId ?? firstAgentSessionId(task)),
);
}
},
[activeSessionId, task, lastAgentSessionId, onSetActiveSessionId, onSetOpenDiffs, openDiffs],
);
const addSession = useCallback(() => {
void (async () => {
const { sessionId } = await taskWorkbenchClient.addSession({ taskId: task.id });
onSetLastAgentSessionId(sessionId);
onSetActiveSessionId(sessionId);
onSyncRouteSession(task.id, sessionId);
})();
}, [task.id, onSetActiveSessionId, onSetLastAgentSessionId, onSyncRouteSession]);
const changeModel = useCallback(
(model: ModelId) => {
if (!promptSession) {
throw new Error(`Unable to change model for task ${task.id} without an active prompt session`);
}
void taskWorkbenchClient.changeModel({
taskId: task.id,
sessionId: promptSession.id,
model,
});
},
[task.id, promptSession],
);
const addAttachment = useCallback(
(filePath: string, lineNumber: number, lineContent: string) => {
if (!promptSession) {
return;
}
const nextAttachment = { id: `${filePath}:${lineNumber}`, filePath, lineNumber, lineContent };
if (attachments.some((attachment) => attachment.filePath === filePath && attachment.lineNumber === lineNumber)) {
return;
}
updateDraft(draft, [...attachments, nextAttachment]);
},
[attachments, draft, promptSession, updateDraft],
);
const removeAttachment = useCallback(
(id: string) => {
updateDraft(
draft,
attachments.filter((attachment) => attachment.id !== id),
);
},
[attachments, draft, updateDraft],
);
const jumpToHistoryEvent = useCallback(
(event: HistoryEvent) => {
setPendingHistoryTarget({ messageId: event.messageId, sessionId: event.sessionId });
if (activeSessionId !== event.sessionId) {
switchSession(event.sessionId);
}
},
[activeSessionId, switchSession],
);
const copyMessage = useCallback(async (message: Message) => {
try {
if (!window.navigator.clipboard) {
throw new Error("Clipboard API unavailable in mock layout");
}
await window.navigator.clipboard.writeText(message.text);
setCopiedMessageId(message.id);
} catch (error) {
logger.error(
{
messageId: message.id,
...createErrorContext(error),
},
"failed_to_copy_transcript_message",
);
}
}, []);
const isOptimisticThinking = pendingMessage !== null && activeAgentSession?.id === pendingMessage.sessionId;
const thinkingTimerLabel =
activeAgentSession?.status === "running" && activeAgentSession.thinkingSinceMs !== null
? formatThinkingDuration(timerNowMs - activeAgentSession.thinkingSinceMs)
: isOptimisticThinking
? formatThinkingDuration(timerNowMs - pendingMessage.sentAt)
: null;
return (
<SPanel>
<TranscriptHeader
task={task}
hasSandbox={hasSandbox}
activeSession={activeAgentSession}
editingField={editingField}
editValue={editValue}
onEditValueChange={setEditValue}
onStartEditingField={startEditingField}
onCommitEditingField={commitEditingField}
onCancelEditingField={cancelEditingField}
onSetActiveSessionUnread={(unread) => {
if (activeAgentSession) {
setSessionUnread(activeAgentSession.id, unread);
}
}}
sidebarCollapsed={sidebarCollapsed}
onToggleSidebar={onToggleSidebar}
onSidebarPeekStart={onSidebarPeekStart}
onSidebarPeekEnd={onSidebarPeekEnd}
rightSidebarCollapsed={rightSidebarCollapsed}
onToggleRightSidebar={onToggleRightSidebar}
onNavigateToUsage={onNavigateToUsage}
/>
<div
style={{
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
backgroundColor: t.surfacePrimary,
overflow: "hidden",
borderTopLeftRadius: "12px",
borderTopRightRadius: rightSidebarCollapsed ? "12px" : 0,
borderBottomLeftRadius: "24px",
borderBottomRightRadius: rightSidebarCollapsed ? "24px" : 0,
border: `1px solid ${t.borderDefault}`,
}}
>
<SessionStrip
task={task}
activeSessionId={activeSessionId}
openDiffs={openDiffs}
editingSessionId={editingSessionId}
editingSessionName={editingSessionName}
onEditingSessionNameChange={setEditingSessionName}
onSwitchSession={switchSession}
onStartRenamingSession={startRenamingSession}
onCommitSessionRename={commitSessionRename}
onCancelSessionRename={cancelSessionRename}
onSetSessionUnread={setSessionUnread}
onCloseSession={closeSession}
onCloseDiffTab={closeDiffTab}
onAddSession={addSession}
sidebarCollapsed={sidebarCollapsed}
/>
{activeDiff ? (
<DiffContent
filePath={activeDiff}
file={task.fileChanges.find((file) => file.path === activeDiff)}
diff={task.diffs[activeDiff]}
onAddAttachment={addAttachment}
/>
) : task.sessions.length === 0 ? (
<ScrollBody>
<div
style={{
minHeight: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "32px",
}}
>
<div
style={{
maxWidth: "420px",
textAlign: "center",
display: "flex",
flexDirection: "column",
gap: "12px",
alignItems: "center",
}}
>
{taskProvisioning ? (
<>
<SpinnerDot size={16} />
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>{taskState.title}</h2>
<p style={{ margin: 0, opacity: 0.75 }}>{taskProvisioningMessage}</p>
</>
) : (
<>
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>Create the first session</h2>
<p style={{ margin: 0, opacity: 0.75 }}>Sessions are where you chat with the agent. Start one now to send the first prompt on this task.</p>
<button
type="button"
onClick={addSession}
style={{
alignSelf: "center",
border: 0,
borderRadius: "999px",
padding: "10px 18px",
background: t.borderMedium,
color: t.textPrimary,
cursor: "pointer",
fontWeight: 600,
}}
>
New session
</button>
</>
)}
</div>
</div>
</ScrollBody>
) : selectedSessionHydrating ? (
<ScrollBody>
<div
style={{
minHeight: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "32px",
}}
>
<div
style={{
maxWidth: "420px",
textAlign: "center",
display: "flex",
flexDirection: "column",
gap: "12px",
alignItems: "center",
}}
>
<SpinnerDot size={16} />
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>Loading session</h2>
<p style={{ margin: 0, opacity: 0.75 }}>Fetching the latest transcript for this session.</p>
</div>
</div>
</ScrollBody>
) : showPendingSessionState ? (
<ScrollBody>
<div
style={{
minHeight: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "32px",
}}
>
<div
style={{
maxWidth: "420px",
textAlign: "center",
display: "flex",
flexDirection: "column",
gap: "12px",
alignItems: "center",
}}
>
{activeAgentSession?.status === "error" ? null : <SpinnerDot size={16} />}
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>
{activeAgentSession?.status === "pending_provision"
? "Provisioning sandbox"
: activeAgentSession?.status === "pending_session_create"
? "Creating session"
: "Session unavailable"}
</h2>
<p style={{ margin: 0, opacity: 0.75 }}>{activeSessionMessage}</p>
{activeAgentSession?.status === "error" ? (
<button
type="button"
onClick={addSession}
style={{
alignSelf: "center",
border: 0,
borderRadius: "999px",
padding: "10px 18px",
background: t.borderMedium,
color: t.textPrimary,
cursor: "pointer",
fontWeight: 600,
}}
>
Retry session
</button>
) : null}
</div>
</div>
</ScrollBody>
) : (
<ScrollBody>
<MessageList
session={activeAgentSession}
scrollRef={scrollRef}
messageRefs={messageRefs}
historyEvents={historyEvents}
onSelectHistoryEvent={jumpToHistoryEvent}
targetMessageId={pendingHistoryTarget && activeSessionId === pendingHistoryTarget.sessionId ? pendingHistoryTarget.messageId : null}
onTargetMessageResolved={() => setPendingHistoryTarget(null)}
copiedMessageId={copiedMessageId}
onCopyMessage={(message) => {
void copyMessage(message);
}}
thinkingTimerLabel={thinkingTimerLabel}
pendingMessage={
pendingMessage && activeAgentSession?.id === pendingMessage.sessionId ? { text: pendingMessage.text, sentAt: pendingMessage.sentAt } : null
}
/>
</ScrollBody>
)}
{!isTerminal && promptSession && (promptSession.status === "ready" || promptSession.status === "running" || promptSession.status === "idle") ? (
<PromptComposer
draft={draft}
textareaRef={textareaRef}
placeholder={!promptSession.created ? "Describe your task..." : "Send a message..."}
attachments={attachments}
defaultModel={defaultModel}
model={promptSession.model}
isRunning={promptSession.status === "running"}
onDraftChange={(value) => updateDraft(value, attachments)}
onSend={sendMessage}
onStop={stopAgent}
onRemoveAttachment={removeAttachment}
onChangeModel={changeModel}
onSetDefaultModel={setDefaultModel}
/>
) : null}
</div>
</SPanel>
);
});
const LEFT_SIDEBAR_DEFAULT_WIDTH = 340;
const RIGHT_SIDEBAR_DEFAULT_WIDTH = 380;
const SIDEBAR_MIN_WIDTH = 220;
const SIDEBAR_MAX_WIDTH = 600;
const RESIZE_HANDLE_WIDTH = 1;
const LEFT_WIDTH_STORAGE_KEY = "foundry:left-sidebar-width";
const RIGHT_WIDTH_STORAGE_KEY = "foundry:right-sidebar-width";
function readStoredWidth(key: string, fallback: number): number {
if (typeof window === "undefined") return fallback;
const stored = window.localStorage.getItem(key);
const parsed = stored ? Number.parseInt(stored, 10) : Number.NaN;
return Number.isFinite(parsed) ? Math.min(Math.max(parsed, SIDEBAR_MIN_WIDTH), SIDEBAR_MAX_WIDTH) : fallback;
}
const PanelResizeHandle = memo(function PanelResizeHandle({ onResizeStart, onResize }: { onResizeStart: () => void; onResize: (deltaX: number) => void }) {
const handlePointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
event.preventDefault();
const startX = event.clientX;
onResizeStart();
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
const handlePointerMove = (moveEvent: PointerEvent) => {
onResize(moveEvent.clientX - startX);
};
const stopResize = () => {
document.body.style.cursor = "";
document.body.style.userSelect = "";
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", stopResize);
};
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", stopResize, { once: true });
},
[onResize, onResizeStart],
);
return (
<div
role="separator"
aria-orientation="vertical"
onPointerDown={handlePointerDown}
style={{
width: `${RESIZE_HANDLE_WIDTH}px`,
flexShrink: 0,
cursor: "col-resize",
backgroundColor: "transparent",
position: "relative",
zIndex: 1,
}}
>
<div
style={{
position: "absolute",
top: 0,
bottom: 0,
left: "-3px",
right: "-3px",
}}
/>
</div>
);
});
const RIGHT_RAIL_MIN_SECTION_HEIGHT = 180;
const RIGHT_RAIL_SPLITTER_HEIGHT = 10;
const DEFAULT_TERMINAL_HEIGHT = 320;
const TERMINAL_HEIGHT_STORAGE_KEY = "foundry:terminal-height";
const RightRail = memo(function RightRail({
organizationId,
task,
activeSessionId,
onOpenDiff,
onArchive,
onRevertFile,
onPublishPr,
onToggleSidebar,
}: {
organizationId: string;
task: Task;
activeSessionId: string | null;
onOpenDiff: (path: string) => void;
onArchive: () => void;
onRevertFile: (path: string) => void;
onPublishPr: () => void;
onToggleSidebar?: () => void;
}) {
const [css] = useStyletron();
const t = useFoundryTokens();
const railRef = useRef<HTMLDivElement>(null);
const [terminalHeight, setTerminalHeight] = useState(() => {
if (typeof window === "undefined") {
return DEFAULT_TERMINAL_HEIGHT;
}
const stored = window.localStorage.getItem(TERMINAL_HEIGHT_STORAGE_KEY);
const parsed = stored ? Number.parseInt(stored, 10) : Number.NaN;
return Number.isFinite(parsed) ? parsed : DEFAULT_TERMINAL_HEIGHT;
});
const clampTerminalHeight = useCallback((nextHeight: number) => {
const railHeight = railRef.current?.getBoundingClientRect().height ?? 0;
const maxHeight = Math.max(RIGHT_RAIL_MIN_SECTION_HEIGHT, railHeight - RIGHT_RAIL_MIN_SECTION_HEIGHT - RIGHT_RAIL_SPLITTER_HEIGHT);
return Math.min(Math.max(nextHeight, 43), maxHeight);
}, []);
useEffect(() => {
if (typeof window === "undefined") {
return;
}
window.localStorage.setItem(TERMINAL_HEIGHT_STORAGE_KEY, String(terminalHeight));
}, [terminalHeight]);
useEffect(() => {
const handleResize = () => {
setTerminalHeight((current) => clampTerminalHeight(current));
};
window.addEventListener("resize", handleResize);
handleResize();
return () => window.removeEventListener("resize", handleResize);
}, [clampTerminalHeight]);
const startResize = useCallback(
(event: ReactPointerEvent) => {
event.preventDefault();
const startY = event.clientY;
const startHeight = terminalHeight;
document.body.style.cursor = "ns-resize";
const handlePointerMove = (moveEvent: PointerEvent) => {
const deltaY = moveEvent.clientY - startY;
setTerminalHeight(clampTerminalHeight(startHeight - deltaY));
};
const stopResize = () => {
document.body.style.cursor = "";
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", stopResize);
};
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", stopResize, { once: true });
},
[clampTerminalHeight, terminalHeight],
);
return (
<div
ref={railRef}
className={css({
minHeight: 0,
flex: 1,
display: "flex",
flexDirection: "column",
backgroundColor: t.surfacePrimary,
})}
>
<div
className={css({
minHeight: `${RIGHT_RAIL_MIN_SECTION_HEIGHT}px`,
flex: 1,
minWidth: 0,
display: "flex",
flexDirection: "column",
})}
>
<RightSidebar
task={task}
activeSessionId={activeSessionId}
onOpenDiff={onOpenDiff}
onArchive={onArchive}
onRevertFile={onRevertFile}
onPublishPr={onPublishPr}
onToggleSidebar={onToggleSidebar}
/>
</div>
<div
className={css({
height: `${terminalHeight}px`,
minHeight: "43px",
backgroundColor: t.surfacePrimary,
overflow: "hidden",
borderBottomRightRadius: "12px",
borderRight: `1px solid ${t.borderDefault}`,
borderBottom: `1px solid ${t.borderDefault}`,
display: "flex",
flexDirection: "column",
})}
>
<TerminalPane
organizationId={organizationId}
taskId={task.id}
onStartResize={startResize}
isExpanded={(() => {
const railHeight = railRef.current?.getBoundingClientRect().height ?? 0;
return railHeight > 0 && terminalHeight >= railHeight * 0.7;
})()}
onExpand={() => {
const railHeight = railRef.current?.getBoundingClientRect().height ?? 0;
const maxHeight = Math.max(RIGHT_RAIL_MIN_SECTION_HEIGHT, railHeight - RIGHT_RAIL_SPLITTER_HEIGHT - 42);
setTerminalHeight(maxHeight);
}}
onCollapse={() => {
setTerminalHeight(43);
}}
/>
</div>
</div>
);
});
interface MockLayoutProps {
organizationId: string;
selectedTaskId?: string | null;
selectedSessionId?: string | null;
}
function MockOrganizationOrgBar() {
const navigate = useNavigate();
const snapshot = useMockAppSnapshot();
const organization = activeMockOrganization(snapshot);
const t = useFoundryTokens();
if (!organization) {
return null;
}
const buttonStyle = {
border: `1px solid ${t.borderMedium}`,
borderRadius: "999px",
padding: "8px 12px",
background: t.interactiveSubtle,
color: t.textPrimary,
cursor: "pointer",
fontSize: "13px",
fontWeight: 600,
} satisfies React.CSSProperties;
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "16px",
padding: "12px 20px",
borderBottom: `1px solid ${t.borderSubtle}`,
background: t.surfaceSecondary,
}}
>
<div style={{ display: "flex", flexDirection: "column", gap: "2px" }}>
<strong style={{ fontSize: "14px", fontWeight: 600 }}>{organization.settings.displayName}</strong>
<span style={{ fontSize: "12px", color: t.textMuted }}>{organization.settings.primaryDomain}</span>
</div>
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
<button
type="button"
style={buttonStyle}
onClick={() => {
void navigate({ to: "/organizations" });
}}
>
Switch org
</button>
<button
type="button"
style={buttonStyle}
onClick={() => {
void navigate({
to: "/organizations/$organizationId/settings",
params: { organizationId: organization.id },
});
}}
>
Settings
</button>
<button
type="button"
style={buttonStyle}
onClick={() => {
void navigate({
to: "/organizations/$organizationId/billing",
params: { organizationId: organization.id },
});
}}
>
Billing
</button>
</div>
</div>
);
}
export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }: MockLayoutProps) {
const [css] = useStyletron();
const t = useFoundryTokens();
const navigate = useNavigate();
const taskWorkbenchClient = useMemo<WorkbenchActions>(
() => ({
createTask: (input) => backendClient.createWorkbenchTask(organizationId, input),
markTaskUnread: (input) => backendClient.markWorkbenchUnread(organizationId, input),
renameTask: (input) => backendClient.renameWorkbenchTask(organizationId, input),
renameBranch: (input) => backendClient.renameWorkbenchBranch(organizationId, input),
archiveTask: async (input) => backendClient.runAction(organizationId, input.taskId, "archive"),
publishPr: (input) => backendClient.publishWorkbenchPr(organizationId, input),
revertFile: (input) => backendClient.revertWorkbenchFile(organizationId, input),
updateDraft: (input) => backendClient.updateWorkbenchDraft(organizationId, input),
sendMessage: (input) => backendClient.sendWorkbenchMessage(organizationId, input),
stopAgent: (input) => backendClient.stopWorkbenchSession(organizationId, input),
setSessionUnread: (input) => backendClient.setWorkbenchSessionUnread(organizationId, input),
renameSession: (input) => backendClient.renameWorkbenchSession(organizationId, input),
closeSession: (input) => backendClient.closeWorkbenchSession(organizationId, input),
addSession: (input) => backendClient.createWorkbenchSession(organizationId, input),
changeModel: (input) => backendClient.changeWorkbenchModel(organizationId, input),
reloadGithubOrganization: () => backendClient.reloadGithubOrganization(organizationId),
reloadGithubPullRequests: () => backendClient.reloadGithubPullRequests(organizationId),
reloadGithubRepository: (repoId) => backendClient.reloadGithubRepository(organizationId, repoId),
reloadGithubPullRequest: (repoId, prNumber) => backendClient.reloadGithubPullRequest(organizationId, repoId, prNumber),
}),
[organizationId],
);
const organizationState = useSubscription(subscriptionManager, "organization", { organizationId });
const organizationRepos = organizationState.data?.repos ?? [];
const taskSummaries = organizationState.data?.taskSummaries ?? [];
const openPullRequests = organizationState.data?.openPullRequests ?? [];
const openPullRequestsByTaskId = useMemo(
() => new Map(openPullRequests.map((pullRequest) => [openPrTaskId(pullRequest.prId), pullRequest])),
[openPullRequests],
);
const selectedOpenPullRequest = useMemo(
() => (selectedTaskId ? (openPullRequestsByTaskId.get(selectedTaskId) ?? null) : null),
[openPullRequestsByTaskId, selectedTaskId],
);
const selectedTaskSummary = useMemo(
() => taskSummaries.find((task) => task.id === selectedTaskId) ?? taskSummaries[0] ?? null,
[selectedTaskId, taskSummaries],
);
const taskState = useSubscription(
subscriptionManager,
"task",
selectedTaskSummary
? {
organizationId,
repoId: selectedTaskSummary.repoId,
taskId: selectedTaskSummary.id,
}
: null,
);
const sessionState = useSubscription(
subscriptionManager,
"session",
selectedTaskSummary && selectedSessionId
? {
organizationId,
repoId: selectedTaskSummary.repoId,
taskId: selectedTaskSummary.id,
sessionId: selectedSessionId,
}
: null,
);
const activeSandbox = useMemo(() => {
if (!taskState.data?.activeSandboxId) return null;
return taskState.data.sandboxes?.find((s) => s.sandboxId === taskState.data!.activeSandboxId) ?? null;
}, [taskState.data?.activeSandboxId, taskState.data?.sandboxes]);
const sandboxState = useSubscription(
subscriptionManager,
"sandboxProcesses",
activeSandbox
? {
organizationId,
sandboxProviderId: activeSandbox.sandboxProviderId,
sandboxId: activeSandbox.sandboxId,
}
: null,
);
const hasSandbox = Boolean(activeSandbox) && sandboxState.status !== "error";
const tasks = useMemo(() => {
const sessionCache = new Map<string, { draft: Task["sessions"][number]["draft"]; transcript: Task["sessions"][number]["transcript"] }>();
if (selectedTaskSummary && taskState.data) {
for (const session of taskState.data.sessionsSummary) {
const cached =
(selectedSessionId && session.id === selectedSessionId ? sessionState.data : undefined) ??
subscriptionManager.getSnapshot("session", {
organizationId,
repoId: selectedTaskSummary.repoId,
taskId: selectedTaskSummary.id,
sessionId: session.id,
});
if (cached) {
sessionCache.set(session.id, {
draft: cached.draft,
transcript: cached.transcript,
});
}
}
}
const hydratedTasks = taskSummaries.map((summary) =>
summary.id === selectedTaskSummary?.id ? toTaskModel(summary, taskState.data, sessionCache) : toTaskModel(summary),
);
const openPrTasks = openPullRequests.map((pullRequest) => toOpenPrTaskModel(pullRequest));
return [...hydratedTasks, ...openPrTasks].sort((left, right) => right.updatedAtMs - left.updatedAtMs);
}, [openPullRequests, selectedTaskSummary, selectedSessionId, sessionState.data, taskState.data, taskSummaries, organizationId]);
const rawRepositories = useMemo(() => groupRepositories(organizationRepos, tasks), [tasks, organizationRepos]);
const appSnapshot = useMockAppSnapshot();
const activeOrg = activeMockOrganization(appSnapshot);
const navigateToUsage = useCallback(() => {
if (activeOrg) {
void navigate({ to: "/organizations/$organizationId/billing" as never, params: { organizationId: activeOrg.id } as never });
}
}, [activeOrg, navigate]);
const [repositoryOrder, setRepositoryOrder] = useState<string[] | null>(null);
const repositories = useMemo(() => {
if (!repositoryOrder) return rawRepositories;
const byId = new Map(rawRepositories.map((p) => [p.id, p]));
const ordered = repositoryOrder.map((id) => byId.get(id)).filter(Boolean) as typeof rawRepositories;
for (const p of rawRepositories) {
if (!repositoryOrder.includes(p.id)) ordered.push(p);
}
return ordered;
}, [rawRepositories, repositoryOrder]);
const [activeSessionIdByTask, setActiveSessionIdByTask] = useState<Record<string, string | null>>({});
const [lastAgentSessionIdByTask, setLastAgentSessionIdByTask] = useState<Record<string, string | null>>({});
const [openDiffsByTask, setOpenDiffsByTask] = useState<Record<string, string[]>>({});
const [selectedNewTaskRepoId, setSelectedNewTaskRepoId] = useState("");
const [leftWidth, setLeftWidth] = useState(() => readStoredWidth(LEFT_WIDTH_STORAGE_KEY, LEFT_SIDEBAR_DEFAULT_WIDTH));
const [rightWidth, setRightWidth] = useState(() => readStoredWidth(RIGHT_WIDTH_STORAGE_KEY, RIGHT_SIDEBAR_DEFAULT_WIDTH));
const leftWidthRef = useRef(leftWidth);
const rightWidthRef = useRef(rightWidth);
const autoCreatingSessionForTaskRef = useRef<Set<string>>(new Set());
const resolvingOpenPullRequestsRef = useRef<Set<string>>(new Set());
const [leftSidebarOpen, setLeftSidebarOpen] = useState(true);
const [rightSidebarOpen, setRightSidebarOpen] = useState(true);
const [leftSidebarPeeking, setLeftSidebarPeeking] = useState(false);
const [materializingOpenPrId, setMaterializingOpenPrId] = useState<string | null>(null);
const showDevPanel = useDevPanel();
const peekTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const startPeek = useCallback(() => {
if (peekTimeoutRef.current) clearTimeout(peekTimeoutRef.current);
setLeftSidebarPeeking(true);
}, []);
const endPeek = useCallback(() => {
peekTimeoutRef.current = setTimeout(() => setLeftSidebarPeeking(false), 200);
}, []);
const reorderRepositories = useCallback(
(fromIndex: number, toIndex: number) => {
const ids = repositories.map((p) => p.id);
const [moved] = ids.splice(fromIndex, 1);
ids.splice(toIndex, 0, moved!);
setRepositoryOrder(ids);
},
[repositories],
);
const [taskOrderByRepository, setTaskOrderByRepository] = useState<Record<string, string[]>>({});
const reorderTasks = useCallback(
(repositoryId: string, fromIndex: number, toIndex: number) => {
const repository = repositories.find((p) => p.id === repositoryId);
if (!repository) return;
const currentOrder = taskOrderByRepository[repositoryId] ?? repository.tasks.map((t) => t.id);
const ids = [...currentOrder];
const [moved] = ids.splice(fromIndex, 1);
ids.splice(toIndex, 0, moved!);
setTaskOrderByRepository((prev) => ({ ...prev, [repositoryId]: ids }));
},
[repositories, taskOrderByRepository],
);
useEffect(() => {
leftWidthRef.current = leftWidth;
window.localStorage.setItem(LEFT_WIDTH_STORAGE_KEY, String(leftWidth));
}, [leftWidth]);
useEffect(() => {
rightWidthRef.current = rightWidth;
window.localStorage.setItem(RIGHT_WIDTH_STORAGE_KEY, String(rightWidth));
}, [rightWidth]);
const startLeftRef = useRef(leftWidth);
const startRightRef = useRef(rightWidth);
const onLeftResize = useCallback((deltaX: number) => {
setLeftWidth(Math.min(Math.max(startLeftRef.current + deltaX, SIDEBAR_MIN_WIDTH), SIDEBAR_MAX_WIDTH));
}, []);
const onLeftResizeStart = useCallback(() => {
startLeftRef.current = leftWidthRef.current;
}, []);
const onRightResize = useCallback((deltaX: number) => {
setRightWidth(Math.min(Math.max(startRightRef.current - deltaX, SIDEBAR_MIN_WIDTH), SIDEBAR_MAX_WIDTH));
}, []);
const onRightResizeStart = useCallback(() => {
startRightRef.current = rightWidthRef.current;
}, []);
const activeTask = useMemo(() => {
const realTasks = tasks.filter((task) => !isOpenPrTaskId(task.id));
if (selectedOpenPullRequest) {
return null;
}
if (selectedTaskId) {
return realTasks.find((task) => task.id === selectedTaskId) ?? realTasks[0] ?? null;
}
return realTasks[0] ?? null;
}, [selectedOpenPullRequest, selectedTaskId, tasks]);
const materializeOpenPullRequest = useCallback(
async (pullRequest: WorkbenchOpenPrSummary) => {
if (resolvingOpenPullRequestsRef.current.has(pullRequest.prId)) {
return;
}
resolvingOpenPullRequestsRef.current.add(pullRequest.prId);
setMaterializingOpenPrId(pullRequest.prId);
try {
const { taskId, sessionId } = await taskWorkbenchClient.createTask({
repoId: pullRequest.repoId,
task: `Continue work on GitHub PR #${pullRequest.number}: ${pullRequest.title}`,
model: "gpt-5.3-codex",
title: pullRequest.title,
onBranch: pullRequest.headRefName,
});
await navigate({
to: "/organizations/$organizationId/tasks/$taskId",
params: {
organizationId,
taskId,
},
search: { sessionId: sessionId ?? undefined },
replace: true,
});
} catch (error) {
setMaterializingOpenPrId((current) => (current === pullRequest.prId ? null : current));
resolvingOpenPullRequestsRef.current.delete(pullRequest.prId);
logger.error(
{
prId: pullRequest.prId,
repoId: pullRequest.repoId,
branchName: pullRequest.headRefName,
...createErrorContext(error),
},
"failed_to_materialize_open_pull_request_task",
);
}
},
[navigate, taskWorkbenchClient, organizationId],
);
useEffect(() => {
if (!selectedOpenPullRequest) {
if (materializingOpenPrId) {
resolvingOpenPullRequestsRef.current.delete(materializingOpenPrId);
}
setMaterializingOpenPrId(null);
return;
}
void materializeOpenPullRequest(selectedOpenPullRequest);
}, [materializeOpenPullRequest, materializingOpenPrId, selectedOpenPullRequest]);
useEffect(() => {
if (activeTask) {
return;
}
if (selectedOpenPullRequest || materializingOpenPrId) {
return;
}
const fallbackTaskId = tasks[0]?.id;
if (!fallbackTaskId) {
return;
}
const fallbackTask = tasks.find((task) => task.id === fallbackTaskId) ?? null;
void navigate({
to: "/organizations/$organizationId/tasks/$taskId",
params: {
organizationId,
taskId: fallbackTaskId,
},
search: { sessionId: fallbackTask?.sessions[0]?.id ?? undefined },
replace: true,
});
}, [activeTask, materializingOpenPrId, navigate, selectedOpenPullRequest, tasks, organizationId]);
const openDiffs = activeTask ? sanitizeOpenDiffs(activeTask, openDiffsByTask[activeTask.id]) : [];
const lastAgentSessionId = activeTask ? sanitizeLastAgentSessionId(activeTask, lastAgentSessionIdByTask[activeTask.id]) : null;
const activeSessionId = activeTask ? sanitizeActiveSessionId(activeTask, activeSessionIdByTask[activeTask.id], openDiffs, lastAgentSessionId) : null;
const selectedSessionHydrating = Boolean(
selectedSessionId && activeSessionId === selectedSessionId && sessionState.status === "loading" && !sessionState.data,
);
const syncRouteSession = useCallback(
(taskId: string, sessionId: string | null, replace = false) => {
void navigate({
to: "/organizations/$organizationId/tasks/$taskId",
params: {
organizationId,
taskId,
},
search: { sessionId: sessionId ?? undefined },
...(replace ? { replace: true } : {}),
});
},
[navigate, organizationId],
);
useEffect(() => {
if (!activeTask) {
return;
}
const resolvedRouteSessionId = sanitizeLastAgentSessionId(activeTask, selectedSessionId);
if (!resolvedRouteSessionId) {
return;
}
if (selectedSessionId !== resolvedRouteSessionId) {
syncRouteSession(activeTask.id, resolvedRouteSessionId, true);
return;
}
if (lastAgentSessionIdByTask[activeTask.id] === resolvedRouteSessionId) {
return;
}
setLastAgentSessionIdByTask((current) => ({
...current,
[activeTask.id]: resolvedRouteSessionId,
}));
setActiveSessionIdByTask((current) => {
const currentActive = current[activeTask.id];
if (currentActive && isDiffTab(currentActive)) {
return current;
}
return {
...current,
[activeTask.id]: resolvedRouteSessionId,
};
});
}, [activeTask, lastAgentSessionIdByTask, selectedSessionId, syncRouteSession]);
useEffect(() => {
if (selectedNewTaskRepoId && organizationRepos.some((repo) => repo.id === selectedNewTaskRepoId)) {
return;
}
const fallbackRepoId =
activeTask?.repoId && organizationRepos.some((repo) => repo.id === activeTask.repoId) ? activeTask.repoId : (organizationRepos[0]?.id ?? "");
if (fallbackRepoId !== selectedNewTaskRepoId) {
setSelectedNewTaskRepoId(fallbackRepoId);
}
}, [activeTask?.repoId, selectedNewTaskRepoId, organizationRepos]);
useEffect(() => {
if (!activeTask) {
return;
}
if (activeTask.sessions.length > 0) {
autoCreatingSessionForTaskRef.current.delete(activeTask.id);
return;
}
if (selectedSessionId) {
return;
}
if (autoCreatingSessionForTaskRef.current.has(activeTask.id)) {
return;
}
autoCreatingSessionForTaskRef.current.add(activeTask.id);
void (async () => {
try {
const { sessionId } = await taskWorkbenchClient.addSession({ taskId: activeTask.id });
syncRouteSession(activeTask.id, sessionId, true);
} catch (error) {
logger.error(
{
taskId: activeTask.id,
...createErrorContext(error),
},
"failed_to_auto_create_workbench_session",
);
// Keep the guard in the set on error to prevent retry storms.
// The guard is cleared when sessions appear (line above) or the task changes.
}
})();
}, [activeTask, selectedSessionId, syncRouteSession, taskWorkbenchClient]);
const createTask = useCallback(
(overrideRepoId?: string, options?: { title?: string; task?: string; branch?: string; onBranch?: string }) => {
void (async () => {
const repoId = overrideRepoId || selectedNewTaskRepoId;
if (!repoId) {
throw new Error("Cannot create a task without an available repo");
}
const { taskId, sessionId } = await taskWorkbenchClient.createTask({
repoId,
task: options?.task ?? "New task",
model: "gpt-5.3-codex",
title: options?.title ?? "New task",
...(options?.branch ? { branch: options.branch } : {}),
...(options?.onBranch ? { onBranch: options.onBranch } : {}),
});
await navigate({
to: "/organizations/$organizationId/tasks/$taskId",
params: {
organizationId,
taskId,
},
search: { sessionId: sessionId ?? undefined },
});
})();
},
[navigate, selectedNewTaskRepoId, taskWorkbenchClient, organizationId],
);
const openDiffTab = useCallback(
(path: string) => {
if (!activeTask) {
throw new Error("Cannot open a diff tab without an active task");
}
setOpenDiffsByTask((current) => {
const existing = sanitizeOpenDiffs(activeTask, current[activeTask.id]);
if (existing.includes(path)) {
return current;
}
return {
...current,
[activeTask.id]: [...existing, path],
};
});
setActiveSessionIdByTask((current) => ({
...current,
[activeTask.id]: diffTabId(path),
}));
},
[activeTask],
);
const selectTask = useCallback(
(id: string) => {
if (isOpenPrTaskId(id)) {
const pullRequest = openPullRequestsByTaskId.get(id);
if (!pullRequest) {
return;
}
void materializeOpenPullRequest(pullRequest);
return;
}
const task = tasks.find((candidate) => candidate.id === id) ?? null;
void navigate({
to: "/organizations/$organizationId/tasks/$taskId",
params: {
organizationId,
taskId: id,
},
search: { sessionId: task?.sessions[0]?.id ?? undefined },
});
},
[materializeOpenPullRequest, navigate, openPullRequestsByTaskId, tasks, organizationId],
);
const markTaskUnread = useCallback((id: string) => {
void taskWorkbenchClient.markTaskUnread({ taskId: id });
}, []);
const renameTask = useCallback(
(id: string) => {
const currentTask = tasks.find((task) => task.id === id);
if (!currentTask) {
throw new Error(`Unable to rename missing task ${id}`);
}
const nextTitle = window.prompt("Rename task", currentTask.title);
if (nextTitle === null) {
return;
}
const trimmedTitle = nextTitle.trim();
if (!trimmedTitle) {
return;
}
void taskWorkbenchClient.renameTask({ taskId: id, value: trimmedTitle });
},
[tasks],
);
const renameBranch = useCallback(
(id: string) => {
const currentTask = tasks.find((task) => task.id === id);
if (!currentTask) {
throw new Error(`Unable to rename missing task ${id}`);
}
const nextBranch = window.prompt("Rename branch", currentTask.branch ?? "");
if (nextBranch === null) {
return;
}
const trimmedBranch = nextBranch.trim();
if (!trimmedBranch) {
return;
}
void taskWorkbenchClient.renameBranch({ taskId: id, value: trimmedBranch });
},
[tasks],
);
const archiveTask = useCallback(() => {
if (!activeTask) {
throw new Error("Cannot archive without an active task");
}
void taskWorkbenchClient.archiveTask({ taskId: activeTask.id });
}, [activeTask]);
const publishPr = useCallback(() => {
if (!activeTask) {
throw new Error("Cannot publish PR without an active task");
}
void taskWorkbenchClient.publishPr({ taskId: activeTask.id });
}, [activeTask]);
const revertFile = useCallback(
(path: string) => {
if (!activeTask) {
throw new Error("Cannot revert a file without an active task");
}
setOpenDiffsByTask((current) => ({
...current,
[activeTask.id]: sanitizeOpenDiffs(activeTask, current[activeTask.id]).filter((candidate) => candidate !== path),
}));
setActiveSessionIdByTask((current) => ({
...current,
[activeTask.id]:
current[activeTask.id] === diffTabId(path)
? sanitizeLastAgentSessionId(activeTask, lastAgentSessionIdByTask[activeTask.id])
: (current[activeTask.id] ?? null),
}));
void taskWorkbenchClient.revertFile({
taskId: activeTask.id,
path,
});
},
[activeTask, lastAgentSessionIdByTask],
);
const isDesktop = !!import.meta.env.VITE_DESKTOP;
const onDragMouseDown = useCallback((event: ReactPointerEvent) => {
if (event.button !== 0) return;
// Tauri v2 IPC: invoke start_dragging on the webview window
const ipc = (window as unknown as Record<string, unknown>).__TAURI_INTERNALS__ as
| {
invoke: (cmd: string, args?: unknown) => Promise<unknown>;
}
| undefined;
if (ipc?.invoke) {
ipc.invoke("plugin:window|start_dragging").catch(() => {});
}
}, []);
const dragRegion = isDesktop ? (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
height: "38px",
zIndex: 9998,
pointerEvents: "none",
}}
>
{/* Background drag target sits behind interactive elements */}
<div
onPointerDown={onDragMouseDown}
style={
{
position: "absolute",
inset: 0,
WebkitAppRegion: "drag",
pointerEvents: "auto",
zIndex: 0,
} as React.CSSProperties
}
/>
</div>
) : null;
const collapsedToggleClass = css({
width: "26px",
height: "26px",
borderRadius: "6px",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
color: t.textTertiary,
position: "relative",
zIndex: 9999,
flexShrink: 0,
":hover": { color: t.textSecondary, backgroundColor: t.interactiveHover },
});
const sidebarTransition = "width 200ms ease";
const contentFrameStyle: React.CSSProperties = {
flex: 1,
minWidth: 0,
display: "flex",
flexDirection: "row",
overflow: "hidden",
marginBottom: "8px",
marginRight: "8px",
marginLeft: leftSidebarOpen ? 0 : "8px",
};
if (!activeTask) {
const isMaterializingSelectedOpenPr = Boolean(selectedOpenPullRequest) || materializingOpenPrId != null;
return (
<>
{dragRegion}
<Shell>
<div
style={{
width: leftSidebarOpen ? `${leftWidth}px` : 0,
flexShrink: 0,
minWidth: 0,
display: "flex",
flexDirection: "column",
overflow: "hidden",
transition: sidebarTransition,
}}
>
<div style={{ minWidth: `${leftWidth}px`, flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
<Sidebar
repositories={repositories}
newTaskRepos={organizationRepos}
selectedNewTaskRepoId={selectedNewTaskRepoId}
activeId={selectedTaskId ?? ""}
onSelect={selectTask}
onCreate={createTask}
onSelectNewTaskRepo={setSelectedNewTaskRepoId}
onMarkUnread={markTaskUnread}
onRenameTask={renameTask}
onRenameBranch={renameBranch}
onReorderRepositories={reorderRepositories}
taskOrderByRepository={taskOrderByRepository}
onReorderTasks={reorderTasks}
onReloadOrganization={() => void taskWorkbenchClient.reloadGithubOrganization()}
onReloadPullRequests={() => void taskWorkbenchClient.reloadGithubPullRequests()}
onReloadRepository={(repoId) => void taskWorkbenchClient.reloadGithubRepository(repoId)}
onReloadPullRequest={(repoId, prNumber) => void taskWorkbenchClient.reloadGithubPullRequest(repoId, prNumber)}
onToggleSidebar={() => setLeftSidebarOpen(false)}
/>
</div>
</div>
<div style={contentFrameStyle}>
{leftSidebarOpen ? <PanelResizeHandle onResizeStart={onLeftResizeStart} onResize={onLeftResize} /> : null}
<SPanel $style={{ backgroundColor: t.surfacePrimary, flex: 1, minWidth: 0 }}>
{!leftSidebarOpen || !rightSidebarOpen ? (
<div style={{ display: "flex", alignItems: "center", padding: "8px 8px 0 8px" }}>
{leftSidebarOpen ? null : (
<div className={collapsedToggleClass} onClick={() => setLeftSidebarOpen(true)}>
<PanelLeft size={14} />
</div>
)}
<div style={{ flex: 1 }} />
{rightSidebarOpen ? null : (
<div className={collapsedToggleClass} onClick={() => setRightSidebarOpen(true)}>
<PanelRight size={14} />
</div>
)}
</div>
) : null}
<ScrollBody>
<div
style={{
minHeight: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "32px",
}}
>
<div
style={{
maxWidth: "420px",
textAlign: "center",
display: "flex",
flexDirection: "column",
gap: "12px",
}}
>
{activeOrg?.github.syncStatus === "syncing" || activeOrg?.github.syncStatus === "pending" ? (
<>
<div
className={css({
width: "24px",
height: "24px",
border: `2px solid ${t.borderSubtle}`,
borderTopColor: t.textSecondary,
borderRadius: "50%",
animationName: {
from: { transform: "rotate(0deg)" },
to: { transform: "rotate(360deg)" },
} as unknown as string,
animationDuration: "0.8s",
animationIterationCount: "infinite",
animationTimingFunction: "linear",
alignSelf: "center",
})}
/>
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>Syncing with GitHub</h2>
<p style={{ margin: 0, opacity: 0.75 }}>
Importing repos from @{activeOrg.github.connectedAccount || "GitHub"}...
{activeOrg.github.importedRepoCount > 0 && <> {activeOrg.github.importedRepoCount} repos imported so far.</>}
</p>
</>
) : isMaterializingSelectedOpenPr && selectedOpenPullRequest ? (
<>
<SpinnerDot />
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>Creating task from pull request</h2>
<p style={{ margin: 0, opacity: 0.75 }}>
Preparing a task for <strong>{selectedOpenPullRequest.title}</strong> on <strong>{selectedOpenPullRequest.headRefName}</strong>.
</p>
</>
) : activeOrg?.github.syncStatus === "error" ? (
<>
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600, color: t.statusError }}>GitHub sync failed</h2>
<p style={{ margin: 0, opacity: 0.75 }}>There was a problem syncing repos from GitHub. Check the dev panel for details.</p>
</>
) : (
<>
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>Create your first task</h2>
<p style={{ margin: 0, opacity: 0.75 }}>
{organizationRepos.length > 0
? "Start from the sidebar to create a task on the first available repo."
: "No repos are available in this organization yet."}
</p>
<button
type="button"
onClick={() => createTask()}
disabled={organizationRepos.length === 0}
style={{
alignSelf: "center",
border: 0,
borderRadius: "999px",
padding: "10px 18px",
background: organizationRepos.length > 0 ? t.borderMedium : t.textTertiary,
color: t.textPrimary,
cursor: organizationRepos.length > 0 ? "pointer" : "not-allowed",
fontWeight: 600,
}}
>
New task
</button>
</>
)}
</div>
</div>
</ScrollBody>
</SPanel>
{rightSidebarOpen ? <PanelResizeHandle onResizeStart={onRightResizeStart} onResize={onRightResize} /> : null}
<div
style={{
width: rightSidebarOpen ? `${rightWidth}px` : 0,
flexShrink: 0,
minWidth: 0,
display: "flex",
flexDirection: "column",
overflow: "hidden",
transition: sidebarTransition,
}}
>
<div style={{ minWidth: `${rightWidth}px`, flex: 1, display: "flex", flexDirection: "column" }}>
<SPanel />
</div>
</div>
</div>
</Shell>
{activeOrg && <GithubInstallationWarning organization={activeOrg} css={css} t={t} />}
{showDevPanel && (
<DevPanel
organizationId={organizationId}
snapshot={{ organizationId, repos: organizationRepos, repositories: rawRepositories, tasks } as TaskWorkbenchSnapshot}
organization={activeOrg}
focusedTask={null}
/>
)}
</>
);
}
return (
<>
{dragRegion}
<Shell $style={{ position: "relative" }}>
<div
style={{
width: leftSidebarOpen ? `${leftWidth}px` : 0,
flexShrink: 0,
minWidth: 0,
display: "flex",
flexDirection: "column",
overflow: "hidden",
transition: sidebarTransition,
}}
>
<div style={{ minWidth: `${leftWidth}px`, flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
<Sidebar
repositories={repositories}
newTaskRepos={organizationRepos}
selectedNewTaskRepoId={selectedNewTaskRepoId}
activeId={selectedTaskId ?? activeTask.id}
onSelect={selectTask}
onCreate={createTask}
onSelectNewTaskRepo={setSelectedNewTaskRepoId}
onMarkUnread={markTaskUnread}
onRenameTask={renameTask}
onRenameBranch={renameBranch}
onReorderRepositories={reorderRepositories}
taskOrderByRepository={taskOrderByRepository}
onReorderTasks={reorderTasks}
onReloadOrganization={() => void taskWorkbenchClient.reloadGithubOrganization()}
onReloadPullRequests={() => void taskWorkbenchClient.reloadGithubPullRequests()}
onReloadRepository={(repoId) => void taskWorkbenchClient.reloadGithubRepository(repoId)}
onReloadPullRequest={(repoId, prNumber) => void taskWorkbenchClient.reloadGithubPullRequest(repoId, prNumber)}
onToggleSidebar={() => setLeftSidebarOpen(false)}
/>
</div>
</div>
{!leftSidebarOpen && leftSidebarPeeking ? (
<>
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.4)",
zIndex: 99,
}}
onClick={() => setLeftSidebarPeeking(false)}
onMouseEnter={endPeek}
/>
<div
style={{
position: "absolute",
top: 0,
left: 0,
bottom: 0,
width: `${leftWidth}px`,
zIndex: 100,
display: "flex",
flexDirection: "column",
boxShadow: "4px 0 24px rgba(0, 0, 0, 0.5)",
}}
onMouseEnter={startPeek}
onMouseLeave={endPeek}
>
<Sidebar
repositories={repositories}
newTaskRepos={organizationRepos}
selectedNewTaskRepoId={selectedNewTaskRepoId}
activeId={selectedTaskId ?? activeTask.id}
onSelect={(id) => {
selectTask(id);
setLeftSidebarPeeking(false);
}}
onCreate={createTask}
onSelectNewTaskRepo={setSelectedNewTaskRepoId}
onMarkUnread={markTaskUnread}
onRenameTask={renameTask}
onRenameBranch={renameBranch}
onReorderRepositories={reorderRepositories}
taskOrderByRepository={taskOrderByRepository}
onReorderTasks={reorderTasks}
onReloadOrganization={() => void taskWorkbenchClient.reloadGithubOrganization()}
onReloadPullRequests={() => void taskWorkbenchClient.reloadGithubPullRequests()}
onReloadRepository={(repoId) => void taskWorkbenchClient.reloadGithubRepository(repoId)}
onReloadPullRequest={(repoId, prNumber) => void taskWorkbenchClient.reloadGithubPullRequest(repoId, prNumber)}
onToggleSidebar={() => {
setLeftSidebarPeeking(false);
setLeftSidebarOpen(true);
}}
/>
</div>
</>
) : null}
<div style={contentFrameStyle}>
{leftSidebarOpen ? <PanelResizeHandle onResizeStart={onLeftResizeStart} onResize={onLeftResize} /> : null}
<div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column" }}>
<TranscriptPanel
taskWorkbenchClient={taskWorkbenchClient}
task={activeTask}
hasSandbox={hasSandbox}
activeSessionId={activeSessionId}
lastAgentSessionId={lastAgentSessionId}
openDiffs={openDiffs}
onSyncRouteSession={syncRouteSession}
onSetActiveSessionId={(sessionId) => {
setActiveSessionIdByTask((current) => ({ ...current, [activeTask.id]: sessionId }));
}}
onSetLastAgentSessionId={(sessionId) => {
setLastAgentSessionIdByTask((current) => ({ ...current, [activeTask.id]: sessionId }));
}}
onSetOpenDiffs={(paths) => {
setOpenDiffsByTask((current) => ({ ...current, [activeTask.id]: paths }));
}}
sidebarCollapsed={!leftSidebarOpen}
onToggleSidebar={() => {
setLeftSidebarPeeking(false);
setLeftSidebarOpen(true);
}}
onSidebarPeekStart={startPeek}
onSidebarPeekEnd={endPeek}
rightSidebarCollapsed={!rightSidebarOpen}
onToggleRightSidebar={() => setRightSidebarOpen(true)}
selectedSessionHydrating={selectedSessionHydrating}
onNavigateToUsage={navigateToUsage}
/>
</div>
{rightSidebarOpen ? <PanelResizeHandle onResizeStart={onRightResizeStart} onResize={onRightResize} /> : null}
<div
style={{
width: rightSidebarOpen ? `${rightWidth}px` : 0,
flexShrink: 0,
minWidth: 0,
display: "flex",
flexDirection: "column",
overflow: "hidden",
transition: sidebarTransition,
}}
>
<div style={{ minWidth: `${rightWidth}px`, flex: 1, display: "flex", flexDirection: "column" }}>
<RightRail
organizationId={organizationId}
task={activeTask}
activeSessionId={activeSessionId}
onOpenDiff={openDiffTab}
onArchive={archiveTask}
onRevertFile={revertFile}
onPublishPr={publishPr}
onToggleSidebar={() => setRightSidebarOpen(false)}
/>
</div>
</div>
</div>
{activeOrg && <GithubInstallationWarning organization={activeOrg} css={css} t={t} />}
{showDevPanel && (
<DevPanel
organizationId={organizationId}
snapshot={{ organizationId, repos: organizationRepos, repositories: rawRepositories, tasks } as TaskWorkbenchSnapshot}
organization={activeOrg}
focusedTask={{
id: activeTask.id,
repoId: activeTask.repoId,
title: activeTask.title,
status: activeTask.status,
runtimeStatus: activeTask.runtimeStatus ?? null,
statusMessage: activeTask.statusMessage ?? null,
branch: activeTask.branch ?? null,
activeSandboxId: activeTask.activeSandboxId ?? null,
activeSessionId: selectedSessionId ?? activeTask.sessions[0]?.id ?? null,
sandboxes: [],
sessions:
activeTask.sessions?.map((tab) => ({
id: tab.id,
sessionId: tab.sessionId ?? null,
sessionName: tab.sessionName ?? tab.id,
agent: tab.agent,
model: tab.model,
status: tab.status,
thinkingSinceMs: tab.thinkingSinceMs ?? null,
unread: tab.unread ?? false,
created: tab.created ?? false,
})) ?? [],
}}
/>
)}
</Shell>
</>
);
}