Improve Daytona sandbox provisioning and frontend UI

Refactor git clone script in Daytona provider to use cleaner shell logic for GitHub token authentication and branch checkout. Add support for private repository clones with token-based auth. Improve Daytona provider error handling and git configuration setup.

Frontend improvements include enhanced dev panel, workspace dashboard, sidebar navigation, and UI components for better task/session management. Update interest manager and backend client to support improved session state handling.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Nathan Flurry 2026-03-13 23:06:24 -07:00
parent 8fb19b50da
commit 098b8113f3
19 changed files with 394 additions and 130 deletions

View file

@ -2,7 +2,9 @@ import { memo, useCallback, useEffect, useMemo, useState } from "react";
import { useStyletron } from "baseui";
import { useFoundryTokens } from "../app/theme";
import { isMockFrontendClient } from "../lib/env";
import { interestManager } from "../lib/interest";
import type { FoundryOrganization, TaskWorkbenchSnapshot, WorkbenchTask } from "@sandbox-agent/foundry-shared";
import type { DebugInterestTopic } from "@sandbox-agent/foundry-client";
interface DevPanelProps {
workspaceId: string;
@ -15,9 +17,25 @@ interface TopicInfo {
key: string;
listenerCount: number;
hasConnection: boolean;
status: "loading" | "connected" | "error";
lastRefresh: number | null;
}
function topicLabel(topic: DebugInterestTopic): string {
switch (topic.topicKey) {
case "app":
return "App";
case "workspace":
return "Workspace";
case "task":
return "Task";
case "session":
return "Session";
case "sandboxProcesses":
return "Sandbox";
}
}
function timeAgo(ts: number | null): string {
if (!ts) return "never";
const seconds = Math.floor((Date.now() - ts) / 1000);
@ -37,8 +55,11 @@ function taskStatusLabel(task: WorkbenchTask): string {
function statusColor(status: string, t: ReturnType<typeof useFoundryTokens>): string {
switch (status) {
case "connected":
case "running":
return t.statusSuccess;
case "loading":
return t.statusWarning;
case "archived":
return t.textMuted;
case "error":
@ -88,33 +109,15 @@ export const DevPanel = memo(function DevPanel({ workspaceId, snapshot, organiza
}, []);
const topics = useMemo((): TopicInfo[] => {
const items: TopicInfo[] = [];
// Workbench subscription topic
items.push({
label: "Workbench",
key: `ws:${workspaceId}`,
listenerCount: 1,
hasConnection: true,
lastRefresh: now,
});
// Per-task tab subscriptions
for (const task of snapshot.tasks ?? []) {
if (task.status === "archived") continue;
for (const tab of task.tabs ?? []) {
items.push({
label: `Tab/${task.title?.slice(0, 16) || task.id.slice(0, 8)}/${tab.sessionName.slice(0, 10)}`,
key: `${workspaceId}:${task.id}:${tab.id}`,
listenerCount: 1,
hasConnection: tab.status === "running",
lastRefresh: tab.status === "running" ? now : null,
});
}
}
return items;
}, [workspaceId, snapshot, now]);
return interestManager.listDebugTopics().map((topic) => ({
label: topicLabel(topic),
key: topic.cacheKey,
listenerCount: topic.listenerCount,
hasConnection: topic.status === "connected",
status: topic.status,
lastRefresh: topic.lastRefreshAt,
}));
}, [now]);
const tasks = snapshot.tasks ?? [];
const repos = snapshot.repos ?? [];
@ -199,6 +202,7 @@ export const DevPanel = memo(function DevPanel({ workspaceId, snapshot, organiza
<span className={css({ fontSize: "10px", color: t.textPrimary, flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" })}>
{topic.label}
</span>
<span className={`${mono} ${css({ color: statusColor(topic.status, t) })}`}>{topic.status}</span>
<span className={`${mono} ${css({ color: t.textMuted })}`}>{topic.key.length > 24 ? `...${topic.key.slice(-20)}` : topic.key}</span>
<span className={`${mono} ${css({ color: t.textTertiary })}`}>{timeAgo(topic.lastRefresh)}</span>
</div>

View file

@ -22,7 +22,7 @@ import { Sidebar } from "./mock-layout/sidebar";
import { TabStrip } from "./mock-layout/tab-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 } from "./mock-layout/ui";
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,
@ -88,6 +88,7 @@ function toLegacyTab(
thinkingSinceMs: summary.thinkingSinceMs,
unread: summary.unread,
created: summary.created,
errorMessage: summary.errorMessage ?? null,
draft: sessionDetail?.draft ?? {
text: "",
attachments: [],
@ -107,7 +108,9 @@ function toLegacyTask(
id: summary.id,
repoId: summary.repoId,
title: detail?.title ?? summary.title,
status: detail?.status ?? summary.status,
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,
@ -117,9 +120,30 @@ function toLegacyTask(
diffs: detail?.diffs ?? {},
fileTree: detail?.fileTree ?? [],
minutesUsed: detail?.minutesUsed ?? 0,
activeSandboxId: detail?.activeSandboxId ?? null,
};
}
function isProvisioningTaskStatus(status: string | null | undefined): boolean {
return status === "new" || String(status ?? "").startsWith("init_");
}
function sessionStateMessage(tab: Task["tabs"][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 groupProjects(repos: Array<{ id: string; label: string }>, tasks: Task[]) {
return repos
.map((repo) => ({
@ -202,6 +226,14 @@ const TranscriptPanel = memo(function TranscriptPanel({
const isTerminal = task.status === "archived";
const historyEvents = useMemo(() => buildHistoryEvents(task.tabs), [task.tabs]);
const activeMessages = useMemo(() => buildDisplayMessages(activeAgentTab), [activeAgentTab]);
const taskProvisioning = isProvisioningTaskStatus(task.runtimeStatus ?? task.status);
const taskProvisioningMessage = task.statusMessage ?? "Provisioning sandbox...";
const activeSessionMessage = sessionStateMessage(activeAgentTab);
const showPendingSessionState =
!activeDiff &&
!!activeAgentTab &&
(activeAgentTab.status === "pending_provision" || activeAgentTab.status === "pending_session_create" || activeAgentTab.status === "error") &&
activeMessages.length === 0;
const draft = promptTab?.draft.text ?? "";
const attachments = promptTab?.draft.attachments ?? [];
@ -619,26 +651,88 @@ const TranscriptPanel = memo(function TranscriptPanel({
display: "flex",
flexDirection: "column",
gap: "12px",
alignItems: "center",
}}
>
<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={addTab}
style={{
alignSelf: "center",
border: 0,
borderRadius: "999px",
padding: "10px 18px",
background: t.borderMedium,
color: t.textPrimary,
cursor: "pointer",
fontWeight: 600,
}}
>
New session
</button>
{taskProvisioning ? (
<>
<SpinnerDot size={16} />
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>Provisioning task</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={addTab}
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>
) : 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",
}}
>
{activeAgentTab?.status === "error" ? null : <SpinnerDot size={16} />}
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>
{activeAgentTab?.status === "pending_provision"
? "Provisioning sandbox"
: activeAgentTab?.status === "pending_session_create"
? "Creating session"
: "Session unavailable"}
</h2>
<p style={{ margin: 0, opacity: 0.75 }}>{activeSessionMessage}</p>
{activeAgentTab?.status === "error" ? (
<button
type="button"
onClick={addTab}
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>
@ -658,7 +752,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
/>
</ScrollBody>
)}
{!isTerminal && promptTab ? (
{!isTerminal && promptTab && (promptTab.status === "ready" || promptTab.status === "running" || promptTab.status === "idle") ? (
<PromptComposer
draft={draft}
textareaRef={textareaRef}

View file

@ -521,6 +521,10 @@ export const Sidebar = memo(function Sidebar({
const isActive = task.id === activeId;
const isDim = task.status === "archived";
const isRunning = task.tabs.some((tab) => tab.status === "running");
const isProvisioning =
String(task.status).startsWith("init_") ||
task.status === "new" ||
task.tabs.some((tab) => tab.status === "pending_provision" || tab.status === "pending_session_create");
const hasUnread = task.tabs.some((tab) => tab.unread);
const isDraft = task.pullRequest == null || task.pullRequest.status === "draft";
const totalAdded = task.fileChanges.reduce((sum, file) => sum + file.added, 0);
@ -592,7 +596,7 @@ export const Sidebar = memo(function Sidebar({
flexShrink: 0,
})}
>
<TaskIndicator isRunning={isRunning} hasUnread={hasUnread} isDraft={isDraft} />
<TaskIndicator isRunning={isRunning} isProvisioning={isProvisioning} hasUnread={hasUnread} isDraft={isDraft} />
</div>
<LabelSmall
$style={{

View file

@ -118,10 +118,21 @@ export const UnreadDot = memo(function UnreadDot() {
);
});
export const TaskIndicator = memo(function TaskIndicator({ isRunning, hasUnread, isDraft }: { isRunning: boolean; hasUnread: boolean; isDraft: boolean }) {
export const TaskIndicator = memo(function TaskIndicator({
isRunning,
isProvisioning,
hasUnread,
isDraft,
}: {
isRunning: boolean;
isProvisioning: boolean;
hasUnread: boolean;
isDraft: boolean;
}) {
const t = useFoundryTokens();
if (isRunning) return <SpinnerDot size={8} />;
if (isProvisioning) return <SpinnerDot size={8} />;
if (hasUnread) return <UnreadDot />;
if (isDraft) return <GitPullRequestDraft size={12} color={t.textSecondary} />;
return <GitPullRequest size={12} color={t.statusSuccess} />;
@ -174,7 +185,7 @@ export const AgentIcon = memo(function AgentIcon({ agent, size = 14 }: { agent:
});
export const TabAvatar = memo(function TabAvatar({ tab }: { tab: AgentTab }) {
if (tab.status === "running") return <SpinnerDot size={8} />;
if (tab.status === "running" || tab.status === "pending_provision" || tab.status === "pending_session_create") return <SpinnerDot size={8} />;
if (tab.unread) return <UnreadDot />;
return <AgentIcon agent={tab.agent} size={13} />;
});

View file

@ -99,7 +99,8 @@ const AGENT_OPTIONS: SelectItem[] = [
function statusKind(status: WorkbenchTaskStatus): StatusTagKind {
if (status === "running") return "positive";
if (status === "new") return "warning";
if (status === "error") return "negative";
if (status === "new" || String(status).startsWith("init_")) return "warning";
return "neutral";
}
@ -497,6 +498,10 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
}
: null,
);
const selectedSessionSummary = useMemo(() => sessionRows.find((session) => session.id === resolvedSessionId) ?? null, [resolvedSessionId, sessionRows]);
const isPendingProvision = selectedSessionSummary?.status === "pending_provision";
const isPendingSessionCreate = selectedSessionSummary?.status === "pending_session_create";
const isSessionError = selectedSessionSummary?.status === "error";
const canStartSession = Boolean(selectedForSession && activeSandbox?.sandboxId);
const startSessionFromTask = async (): Promise<{ id: string; status: "running" | "idle" | "error" }> => {
@ -1363,19 +1368,47 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
>
{resolvedSessionId && sessionState.status === "loading" ? <Skeleton rows={2} height="90px" /> : null}
{selectedSessionSummary && (isPendingProvision || isPendingSessionCreate) ? (
<div
className={css({
display: "flex",
flexDirection: "column",
gap: theme.sizing.scale300,
padding: theme.sizing.scale500,
border: `1px solid ${theme.colors.borderOpaque}`,
backgroundColor: theme.colors.backgroundSecondary,
marginBottom: theme.sizing.scale400,
})}
>
<LabelSmall marginTop="0" marginBottom="0">
{isPendingProvision ? "Provisioning sandbox..." : "Creating session..."}
</LabelSmall>
<Skeleton rows={1} height="32px" />
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
{selectedForSession?.statusMessage ?? (isPendingProvision ? "The task is still provisioning." : "The session is being created.")}
</ParagraphSmall>
</div>
) : null}
{transcript.length === 0 && !(resolvedSessionId && sessionState.status === "loading") ? (
<EmptyState testId="session-transcript-empty">
{selectedForSession.runtimeStatus === "error" && selectedForSession.statusMessage
? `Session failed: ${selectedForSession.statusMessage}`
: !activeSandbox?.sandboxId
? selectedForSession.statusMessage
? `Sandbox unavailable: ${selectedForSession.statusMessage}`
: "This task is still provisioning its sandbox."
: staleSessionId
? `Session ${staleSessionId} is unavailable. Start a new session to continue.`
: resolvedSessionId
? "No transcript events yet. Send a prompt to start this session."
: "No active session for this task."}
: isPendingProvision
? (selectedForSession.statusMessage ?? "Provisioning sandbox...")
: isPendingSessionCreate
? "Creating session..."
: isSessionError
? (selectedSessionSummary?.errorMessage ?? "Session failed to start.")
: !activeSandbox?.sandboxId
? selectedForSession.statusMessage
? `Sandbox unavailable: ${selectedForSession.statusMessage}`
: "This task is still provisioning its sandbox."
: staleSessionId
? `Session ${staleSessionId} is unavailable. Start a new session to continue.`
: resolvedSessionId
? "No transcript events yet. Send a prompt to start this session."
: "No active session for this task."}
</EmptyState>
) : null}
@ -1442,7 +1475,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
onChange={(event) => setDraft(event.target.value)}
placeholder="Send a follow-up prompt to this session"
rows={5}
disabled={!activeSandbox?.sandboxId}
disabled={!activeSandbox?.sandboxId || isPendingProvision || isPendingSessionCreate || isSessionError}
overrides={textareaTestIdOverrides("task-session-prompt")}
/>
<div
@ -1460,7 +1493,14 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
void sendPrompt.mutateAsync(prompt);
}}
disabled={
sendPrompt.isPending || createSession.isPending || !selectedForSession || !activeSandbox?.sandboxId || draft.trim().length === 0
sendPrompt.isPending ||
createSession.isPending ||
!selectedForSession ||
!activeSandbox?.sandboxId ||
isPendingProvision ||
isPendingSessionCreate ||
isSessionError ||
draft.trim().length === 0
}
>
<span
@ -1837,7 +1877,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
}}
data-testid="task-create-submit"
>
Create Task
{createTask.isPending ? "Creating..." : "Create Task"}
</Button>
</ModalFooter>
</Modal>