mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 11:02:20 +00:00
2282 lines
82 KiB
TypeScript
2282 lines
82 KiB
TypeScript
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>
|
||
</>
|
||
);
|
||
}
|